Author SHA1 Message Date
jb d9924a95b9 (feat) Discord build share 2026-08-02 12:00:30 -04:00
jb d09b6220ee feat: defer advanced build calculations
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s
2026-08-02 03:07:12 -04:00
jb c61b9832dd feat: add footer support link 2026-08-02 01:53:29 -04:00
jb b7c7705266 test: isolate runtime ownership stubs
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy production / validate (push) Canceled after 14s
Validate and deploy staging / validate (push) Successful in 31s
Validate and deploy staging / deploy (push) Successful in 2m26s
2026-08-02 01:22:16 -04:00
jb 25d9241b51 feat: migrate secrets to Infisical
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Failing after 22s
Validate and deploy staging / deploy (push) Skipped
2026-08-02 01:19:41 -04:00
jb 9089f4a257 fix: schedule official news cover backfill
Validate and deploy staging / deploy (push) Successful in 26s
Validate and deploy production / deploy (push) Successful in 33s
Validate and deploy staging / validate (push) Successful in 22s
Validate and deploy production / validate (push) Successful in 26s
2026-08-01 22:34:07 -04:00
jb 92e1f58320 fix: backfill official news covers after migration
Validate and deploy staging / validate (push) Successful in 23s
Validate and deploy production / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 29s
Validate and deploy production / deploy (push) Failing after 20s
2026-08-01 22:30:21 -04:00
jb dc08eefce6 feat: link official news to Ubisoft
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy production / deploy (push) Successful in 49s
Validate and deploy staging / deploy (push) Successful in 28s
2026-08-01 22:26:52 -04:00
jb bc40a737a8 fix: skip husky in production image
Validate and deploy staging / validate (push) Successful in 24s
Validate and deploy production / validate (push) Successful in 26s
Validate and deploy staging / deploy (push) Successful in 1m16s
Validate and deploy production / deploy (push) Successful in 34s
2026-08-01 21:47:15 -04:00
jb dd84dff055 feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s
2026-08-01 21:42:09 -04:00
jb b47e2588e9 feat: refine public gear cards
Validate and deploy staging / validate (push) Successful in 21s
Validate and deploy production / validate (push) Successful in 25s
Validate and deploy staging / deploy (push) Successful in 42s
Validate and deploy production / deploy (push) Successful in 30s
2026-08-01 02:01:10 -04:00
jb 1998e87309 feat: support extra health state mechanics
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 23s
Validate and deploy production / deploy (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 30s
2026-07-31 23:55:49 -04:00
jb 9ac2fe47cf feat: show item renders in public database 2026-07-31 23:33:58 -04:00
340 changed files with 87866 additions and 38826 deletions
@@ -1,32 +1,53 @@
---
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
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
1. Stop only this repository's local development server. The embedded database must be closed during export.
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.
1. Stop only this repository's local development server. The embedded database
must be closed during export.
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
1. Confirm the current production deployment is healthy and no Gitea production deployment is running.
2. On `f302`, stop only the production `app` and `scheduler` services from `/opt/shd-build-production`.
3. Run `node scripts/db-backup.mjs` through the production Compose service. Record and retain the JSON backup path before any destructive work.
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 `environment-bootstrap-import.mjs` with `ALLOW_PRODUCTION_BOOTSTRAP=true`, the copied bundle mounted read-only, `--target production`, and `--confirm-empty-target-replace`.
7. Start `app` and `scheduler`, wait for app health, then remove the remote transfer bundle.
1. Confirm the current production deployment is healthy and no Gitea production
deployment is running.
2. On `f302`, stop only the production `app` and `scheduler` services from
`/opt/shd-build-production`.
3. Run `node scripts/db-backup.mjs` through the production Compose service.
Record and retain the JSON backup path before any destructive work.
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
1. Confirm the importer reports the expected row, avatar, and managed-image counts.
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok` database check.
1. Confirm the importer reports the expected row, avatar, and managed-image
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.
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 delete the production Compose volume.
- 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.
-32
View File
@@ -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
View File
@@ -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
# calculation authoring assistants. Use a project-scoped, Responses-only restricted key.
OPENAI_MECHANIC_API_KEY=
@@ -13,7 +16,8 @@ TDR_DATABASE_PATH=./data/shd-build-development.db
# Persistent directory for normalized user-uploaded 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
# Optional fixed credentials used only when initializing or explicitly resetting
@@ -25,6 +29,7 @@ DEFAULT_USER_PASSWORD=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Proxied development: https://dev.shd.build/auth/discord/callback
# Staging: https://stage.shd.build/auth/discord/callback
# Production: https://shd.build/auth/discord/callback
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
@@ -45,9 +50,14 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=SHD.build <no-reply@shd.build>
# Long random bearer token used only by the internal container scheduler.
SCHEDULER_TOKEN=
# Cloudflare Turnstile challenge keys for anonymous Guide activity.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
REMOVE_BG_API_KEY=
REMOVE_BG_LOW_CREDIT_THRESHOLD=5
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
REMOVE_BG_CREDIT_CHECK_HOURS=6
# /shared/scheduler
SCHEDULER_TOKEN=
-32
View File
@@ -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==
-35
View File
@@ -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==
+54 -17
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
concurrency:
group: shd-build-production
group: shd-build-host-maintenance
cancel-in-progress: false
jobs:
@@ -21,7 +21,7 @@ jobs:
run: npm ci
- name: Validate application
run: npm run check
run: npm run check && npm test
- name: Build Node application
run: npm run build:app
@@ -33,30 +33,63 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- name: Install environment tooling
run: npm ci
- name: Prepare production configuration
env:
DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }}
SCHEDULER_TOKEN_PRODUCTION: ${{ secrets.SCHEDULER_TOKEN_PRODUCTION }}
INFISICAL_APP_CLIENT_ID:
${{ 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_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
COMMIT_SHA: ${{ gitea.sha }}
BUILD_NUMBER: ${{ gitea.run_number }}
run: |
set -eu
: "${DOTENV_PRIVATE_KEY_PRODUCTION:?DOTENV_PRIVATE_KEY_PRODUCTION is required}"
: "${SCHEDULER_TOKEN_PRODUCTION:?SCHEDULER_TOKEN_PRODUCTION is required}"
umask 077
: "${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}"
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"
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$deployment_path/"
DOTENV_PRIVATE_KEY_PRODUCTION="$DOTENV_PRIVATE_KEY_PRODUCTION" npx dotenvx decrypt -f .env.production --stdout > "$deployment_path/.env.production.runtime"
printf '\nSCHEDULER_TOKEN=%s\n' "$SCHEDULER_TOKEN_PRODUCTION" >> "$deployment_path/.env.production.runtime"
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"
chmod 600 "$deployment_path/.env.production.runtime" "$deployment_path/.env.deploy"
chmod 755 "$deployment_path/deploy-production.sh" "$deployment_path/bootstrap-production.sh" "$deployment_path/sync-production-isac-to-staging.sh"
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
candidate_path="$deployment_path/candidate-$release_id"
rm -rf "$candidate_path"
mkdir -p "$candidate_path"
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-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-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
env:
@@ -64,8 +97,12 @@ jobs:
COMMIT_SHA: ${{ gitea.sha }}
run: |
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
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"
+53 -13
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
concurrency:
group: shd-build-staging
group: shd-build-host-maintenance
cancel-in-progress: false
jobs:
@@ -21,7 +21,7 @@ jobs:
run: npm ci
- name: Validate application
run: npm run check
run: npm run check && npm test
- name: Build Node application
run: npm run build:app
@@ -33,26 +33,62 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- name: Install environment tooling
run: npm ci
- name: Prepare deployment configuration
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_STAGE_PORT: ${{ vars.STAGE_PORT }}
COMMIT_SHA: ${{ gitea.sha }}
BUILD_NUMBER: ${{ gitea.run_number }}
run: |
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}"
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"
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$deployment_path/"
DOTENV_PRIVATE_KEY_STAGING="$DOTENV_PRIVATE_KEY_STAGING" npx dotenvx decrypt -f .env.staging --stdout > "$deployment_path/.env.staging.runtime"
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"
chmod 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy"
chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh"
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
candidate_path="$deployment_path/candidate-$release_id"
rm -rf "$candidate_path"
mkdir -p "$candidate_path"
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
env:
@@ -60,8 +96,12 @@ jobs:
COMMIT_SHA: ${{ gitea.sha }}
run: |
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
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"
+25 -8
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
concurrency:
group: shd-build-staging
group: shd-build-host-maintenance
cancel-in-progress: false
jobs:
@@ -25,15 +25,32 @@ jobs:
compose() {
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"
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 run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name"
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \
cp "/app/data/exports/$snapshot_name" /export/isac-stage.json
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
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
uses: actions/upload-artifact@v3
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
concurrency:
group: shd-build-production
group: shd-build-host-maintenance
cancel-in-progress: false
jobs:
@@ -25,15 +25,32 @@ jobs:
compose() {
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"
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 run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name"
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \
cp "/app/data/exports/$snapshot_name" /export/isac-production.json
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
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
uses: actions/upload-artifact@v3
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
concurrency:
group: shd-build-production-to-staging
group: shd-build-host-maintenance
cancel-in-progress: false
jobs:
+7 -4
View File
@@ -17,11 +17,10 @@ Thumbs.db
.env.*
!.env.example
!.env.test
!.env.development
!.env.staging
!.env.production
.env.keys
.env.keys.*
.infisical.*.client-id
.infisical.*.client-secret
# Embedded Turso database files
/data/*.db
@@ -38,4 +37,8 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
_img/
tmp/
tmp/
# Python caches
__pycache__/
*.py[cod]
+1
View File
@@ -0,0 +1 @@
npx lint-staged
@@ -2,29 +2,30 @@
target: application route system
total_score: 28
max_score: 40
na_heuristics:
na_heuristics:
p0_count: 0
p1_count: 2
timestamp: 2026-07-26T15-49-23Z
slug: src-routes
---
# SHD.build route-system critique
## Design Health Score
| # | Heuristic | Score | Key issue |
|---|---|---:|---|
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
| # | Heuristic | Score | Key issue |
| --------- | ------------------------------- | --------: | ------------------------------------------------------------------------------------------------------------------------ |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
## 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
warning for the two-pixel semantic notification rail in
`src/routes/account/+page.svelte`, and six advisory colors outside the documented
palette in the homepage and account route. The rail is largely a detector false
positive because it conveys semantic message state, but its colors should still
come from named tokens. The homepage build colors are data-like values rather
than structural CSS, yet they also bypass the maintained palette.
`src/routes/account/+page.svelte`, and six advisory colors outside the
documented palette in the homepage and account route. The rail is largely a
detector false positive because it conveys semantic message state, but its
colors should still come from named tokens. The homepage build colors are
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.
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
system-state blocks, four KPI filters, search, pagination, row-size controls,
six table columns, and repeated Edit actions. Experienced operators can scan
it, but contributors and new Oversight users must decode the whole information
six table columns, and repeated Edit actions. Experienced operators can scan it,
but contributors and new Oversight users must decode the whole information
architecture before acting.
**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
Four of eight Database cards show zero records and “Awaiting contributions,”
but look and behave like full destinations. That creates repeated anticipation
Four of eight Database cards show zero records and “Awaiting contributions,” but
look and behave like full destinations. That creates repeated anticipation
followed by a low-value endpoint.
**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
Muted database descriptions, inactive navigation, unit sublabels, and
pagination text recede heavily. KPI filters resemble static metrics, table rows
look selectable while only the small Edit action works, and a Search button
remains beside an input that searches as the user types.
Muted database descriptions, inactive navigation, unit sublabels, and pagination
text recede heavily. KPI filters resemble static metrics, table rows look
selectable while only the small Edit action works, and a Search button remains
beside an input that searches as the user types.
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
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
promise useful intelligence, but visually equal empty categories produce a
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
operators, then the uniform density creates fatigue with little calm focus.
The most important missing reassurance is a plain statement of what Add/Edit
will do to live data.
operators, then the uniform density creates fatigue with little calm focus. The
most important missing reassurance is a plain statement of what Add/Edit will do
to live data.
## Persona Red Flags
+220 -22
View File
@@ -8,133 +8,331 @@
"role": "neutral",
"displayName": "Operations Black",
"canonical": "#0a0c0d",
"tonalRamp": ["#050606", "#0a0c0d", "#171a1b", "#303536", "#555c5e", "#858b8d", "#c1c4c2", "#f0f0eb"]
"tonalRamp": [
"#050606",
"#0a0c0d",
"#171a1b",
"#303536",
"#555c5e",
"#858b8d",
"#c1c4c2",
"#f0f0eb"
]
},
"terminal-black": {
"role": "neutral",
"displayName": "Terminal Black",
"canonical": "#0d0f10",
"tonalRamp": ["#050606", "#0d0f10", "#1b1e1f", "#34393a", "#596062", "#888f91", "#c3c7c6", "#f1f2ee"]
"tonalRamp": [
"#050606",
"#0d0f10",
"#1b1e1f",
"#34393a",
"#596062",
"#888f91",
"#c3c7c6",
"#f1f2ee"
]
},
"terminal-surface": {
"role": "neutral",
"displayName": "Terminal Surface",
"canonical": "#111415",
"tonalRamp": ["#070808", "#111415", "#202425", "#393e3f", "#5f6668", "#8d9496", "#c6cac9", "#f2f3ef"]
"tonalRamp": [
"#070808",
"#111415",
"#202425",
"#393e3f",
"#5f6668",
"#8d9496",
"#c6cac9",
"#f2f3ef"
]
},
"terminal-graphite": {
"role": "neutral",
"displayName": "Terminal Graphite",
"canonical": "#141718",
"tonalRamp": ["#080a0a", "#111415", "#202425", "#343a3b", "#565e60", "#858d8f", "#c0c5c3", "#f0f1ed"]
"tonalRamp": [
"#080a0a",
"#111415",
"#202425",
"#343a3b",
"#565e60",
"#858d8f",
"#c0c5c3",
"#f0f1ed"
]
},
"active-graphite": {
"role": "neutral",
"displayName": "Active Graphite",
"canonical": "#15191a",
"tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"]
"tonalRamp": [
"#080a0a",
"#15191a",
"#252a2b",
"#3e4445",
"#626a6c",
"#909799",
"#c8cccb",
"#f2f3f0"
]
},
"modeled-data-surface": {
"role": "neutral",
"displayName": "Modeled Data Surface",
"canonical": "#0d1213",
"tonalRamp": ["#050708", "#0d1213", "#1c2527", "#334043", "#526165", "#7d8b8e", "#bac2c2", "#eff1ef"]
"tonalRamp": [
"#050708",
"#0d1213",
"#1c2527",
"#334043",
"#526165",
"#7d8b8e",
"#bac2c2",
"#eff1ef"
]
},
"structural-line": {
"role": "neutral",
"displayName": "Structural Line",
"canonical": "#303536",
"tonalRamp": ["#0d0f10", "#1b1f20", "#303536", "#4b5152", "#6a7274", "#92999b", "#c3c8c7", "#f0f1ee"]
"tonalRamp": [
"#0d0f10",
"#1b1f20",
"#303536",
"#4b5152",
"#6a7274",
"#92999b",
"#c3c8c7",
"#f0f1ee"
]
},
"structural-line-soft": {
"role": "neutral",
"displayName": "Structural Line Soft",
"canonical": "#272c2d",
"tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"]
"tonalRamp": [
"#0b0d0d",
"#181b1c",
"#272c2d",
"#43494a",
"#656d6f",
"#8e9597",
"#c2c6c5",
"#eff0ed"
]
},
"structural-line-strong": {
"role": "neutral",
"displayName": "Structural Line Strong",
"canonical": "#424849",
"tonalRamp": ["#111415", "#252a2b", "#424849", "#5d6567", "#7c8587", "#a0a7a8", "#c9cdcc", "#f1f2ef"]
"tonalRamp": [
"#111415",
"#252a2b",
"#424849",
"#5d6567",
"#7c8587",
"#a0a7a8",
"#c9cdcc",
"#f1f2ef"
]
},
"modeled-data-line": {
"role": "neutral",
"displayName": "Modeled Data Line",
"canonical": "#3b4648",
"tonalRamp": ["#0e1314", "#20292b", "#3b4648", "#566366", "#758184", "#9aa3a5", "#c6cbca", "#f0f2ef"]
"tonalRamp": [
"#0e1314",
"#20292b",
"#3b4648",
"#566366",
"#758184",
"#9aa3a5",
"#c6cbca",
"#f0f2ef"
]
},
"field-manual-white": {
"role": "neutral",
"displayName": "Field Manual White",
"canonical": "#ecebe5",
"tonalRamp": ["#252522", "#44433f", "#66655f", "#898780", "#aaa8a1", "#cac8c1", "#ecebe5", "#faf9f5"]
"tonalRamp": [
"#252522",
"#44433f",
"#66655f",
"#898780",
"#aaa8a1",
"#cac8c1",
"#ecebe5",
"#faf9f5"
]
},
"telemetry-muted": {
"role": "neutral",
"displayName": "Telemetry Muted",
"canonical": "#858b8d",
"tonalRamp": ["#151718", "#292d2e", "#414647", "#5e6466", "#858b8d", "#a6abad", "#cbd0cf", "#f1f2ef"]
"tonalRamp": [
"#151718",
"#292d2e",
"#414647",
"#5e6466",
"#858b8d",
"#a6abad",
"#cbd0cf",
"#f1f2ef"
]
},
"telemetry-dim": {
"role": "neutral",
"displayName": "Telemetry Dim",
"canonical": "#5d6567",
"tonalRamp": ["#111314", "#222627", "#373c3d", "#4b5254", "#5d6567", "#858d8f", "#bcc1c0", "#eef0ed"]
"tonalRamp": [
"#111314",
"#222627",
"#373c3d",
"#4b5254",
"#5d6567",
"#858d8f",
"#bcc1c0",
"#eef0ed"
]
},
"signal-orange": {
"role": "primary",
"displayName": "Signal Orange",
"canonical": "#f6a821",
"tonalRamp": ["#2b1a05", "#4c2f08", "#754a0d", "#a66c14", "#d7901c", "#f6a821", "#ffd278", "#fff1cf"]
"tonalRamp": [
"#2b1a05",
"#4c2f08",
"#754a0d",
"#a66c14",
"#d7901c",
"#f6a821",
"#ffd278",
"#fff1cf"
]
},
"signal-orange-hover": {
"role": "primary",
"displayName": "Signal Orange Hover",
"canonical": "#ffc45d",
"tonalRamp": ["#2d1f08", "#51380d", "#785515", "#a97824", "#d99d39", "#ffc45d", "#ffdda0", "#fff3d9"]
"tonalRamp": [
"#2d1f08",
"#51380d",
"#785515",
"#a97824",
"#d99d39",
"#ffc45d",
"#ffdda0",
"#fff3d9"
]
},
"system-success": {
"role": "secondary",
"displayName": "System Success",
"canonical": "#75c08c",
"tonalRamp": ["#102a19", "#194328", "#275f39", "#3f7d50", "#5c9e6d", "#75c08c", "#a8dcba", "#e2f4e8"]
"tonalRamp": [
"#102a19",
"#194328",
"#275f39",
"#3f7d50",
"#5c9e6d",
"#75c08c",
"#a8dcba",
"#e2f4e8"
]
},
"system-danger": {
"role": "secondary",
"displayName": "System Danger",
"canonical": "#df8069",
"tonalRamp": ["#32130e", "#542219", "#793428", "#a54a39", "#c9634f", "#df8069", "#edae9f", "#f8dfd9"]
"tonalRamp": [
"#32130e",
"#542219",
"#793428",
"#a54a39",
"#c9634f",
"#df8069",
"#edae9f",
"#f8dfd9"
]
},
"system-info": {
"role": "secondary",
"displayName": "System Information",
"canonical": "#70b9d6",
"tonalRamp": ["#0d2630", "#163f50", "#245b70", "#387991", "#5599b3", "#70b9d6", "#a6d5e7", "#e1f1f7"]
"tonalRamp": [
"#0d2630",
"#163f50",
"#245b70",
"#387991",
"#5599b3",
"#70b9d6",
"#a6d5e7",
"#e1f1f7"
]
},
"quality-standard": {
"role": "tertiary",
"displayName": "Standard Quality",
"canonical": "#69b66f",
"tonalRamp": ["#0e2912", "#17431d", "#255e2b", "#397b3e", "#519a56", "#69b66f", "#9dd4a1", "#dff0e0"]
"tonalRamp": [
"#0e2912",
"#17431d",
"#255e2b",
"#397b3e",
"#519a56",
"#69b66f",
"#9dd4a1",
"#dff0e0"
]
},
"quality-specialized": {
"role": "tertiary",
"displayName": "Specialized Quality",
"canonical": "#4f9ed8",
"tonalRamp": ["#0c2435", "#123c59", "#1d567d", "#2d72a3", "#408dc4", "#4f9ed8", "#8dc5ea", "#d9edf8"]
"tonalRamp": [
"#0c2435",
"#123c59",
"#1d567d",
"#2d72a3",
"#408dc4",
"#4f9ed8",
"#8dc5ea",
"#d9edf8"
]
},
"quality-superior": {
"role": "tertiary",
"displayName": "Superior Quality",
"canonical": "#9b68d4",
"tonalRamp": ["#21132f", "#38204f", "#522f70", "#704594", "#8959b7", "#9b68d4", "#c3a2e5", "#eee1f7"]
"tonalRamp": [
"#21132f",
"#38204f",
"#522f70",
"#704594",
"#8959b7",
"#9b68d4",
"#c3a2e5",
"#eee1f7"
]
},
"quality-high-end": {
"role": "tertiary",
"displayName": "High-end Quality",
"canonical": "#e4bd3f",
"tonalRamp": ["#2d2408", "#4c3c0d", "#6f5815", "#95771f", "#bd992d", "#e4bd3f", "#f0d982", "#fbf2ce"]
"tonalRamp": [
"#2d2408",
"#4c3c0d",
"#6f5815",
"#95771f",
"#bd992d",
"#e4bd3f",
"#f0d982",
"#fbf2ce"
]
}
},
"typographyMeta": {
+6
View File
@@ -0,0 +1,6 @@
{
"workspaceId": "2ee94b27-e599-4194-9797-3372d42e53f6",
"defaultEnvironment": "dev",
"defaultSecretPath": "/app",
"domain": "https://secrets.jb.codes"
}
+5
View File
@@ -0,0 +1,5 @@
# Ignore artifacts:
build
coverage
tmp
_img
+17
View File
@@ -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
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
"recommendations": ["svelte.svelte-vscode"]
}
+176 -65
View File
@@ -2,39 +2,70 @@
## 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).
2. Keep architecture, security-sensitive behavior, database migrations, shared data-model decisions, and complex debugging with a suitably capable agent.
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.
1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused
code discovery, repetitive call-site updates, documentation, isolated test
coverage).
2. Keep architecture, security-sensitive behavior, database migrations, shared
data-model decisions, and complex debugging with a suitably capable agent.
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
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
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.
- 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.
- 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.
- 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.
- 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.
- 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
The `.agents/skills/` directory contains repo-specific skill files. Key skills:
- `stop-slop` — apply to documentation prose changes
- `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
- `webapp-testing` — Playwright-based local web app testing
- `stop-slop`: apply to documentation prose changes
- `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
- `webapp-testing`: Playwright-based local web app testing
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 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
- **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`.
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is `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 dev database path is `./data/shd-build-development.db` (set in `.env.development`).
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` and `PASSKEY_RP_ID` must match the public origin.
- **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`.
- **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`.
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is
`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.
- **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
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.
2. Start one hidden `npm run dev` process from the repository root. Do not leave duplicate project development servers running.
1. Inspect running processes and stop only this project's development-server
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.
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
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
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
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.
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.
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.
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.
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.
## 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`.
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`.
3. Capture changes to the primary row and its relationships. Include meaningful 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.
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write
directly to `audit_log`.
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`.
3. Capture changes to the primary row and its relationships. Include meaningful
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
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.
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.
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.
1. Prefer one current validated schema over compatibility readers, repair
branches, dual writes, or fallback parsing for obsolete formats.
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.
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
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
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
1. Stop only this project's development-server process tree.
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.
4. If an operation fails, preserve logs and the verified backup rather than improvising against the database.
3. Apply the migration or maintenance operation. Verify schema state, record
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.
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
View File
@@ -2,7 +2,8 @@
FROM node:24-bookworm-slim AS dependencies
WORKDIR /app
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
COPY vite.config.ts tsconfig.json ./
@@ -13,24 +14,42 @@ RUN npm run build:app
FROM node:24-bookworm-slim AS runtime-dependencies
WORKDIR /app
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
ARG TARGETARCH=amd64
ARG INFISICAL_CLI_VERSION=0.43.116
LABEL org.opencontainers.image.title="shd-build"
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000 \
INFISICAL_DOMAIN=https://secrets.jb.codes \
INFISICAL_DISABLE_UPDATE_CHECK=true \
TDR_DATABASE_PATH=/app/data/shd-build.db \
TDR_AVATAR_STORAGE_PATH=/app/data/avatars \
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
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=build --chown=node:node /app/build ./build
COPY --chown=node:node database ./database
COPY --chown=node:node scripts ./scripts
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
USER node
RUN chmod 755 ./deploy/container-entrypoint.sh ./scripts/runtime-launcher.mjs && mkdir -p /app/data && chown node:node /app/data && chmod 0770 /app/data
EXPOSE 3000
VOLUME ["/app/data"]
ENTRYPOINT ["./deploy/container-entrypoint.sh"]
+13 -12
View File
@@ -8,7 +8,7 @@ web
## 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.
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
SHD.build is an independent community intelligence platform for *The Division
Resurgence*. It includes a public game database, calculated loadout builder,
SHD.build is an independent community intelligence platform for _The Division
Resurgence_. It includes a public game database, calculated loadout builder,
shared builds, guides, news, and community participation.
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
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.
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.
- Persistent application data uses the embedded `@tursodatabase/database`
package. Turso Cloud is not required.
- Development, staging, and production use distinct databases. Embedded
database files and uploaded assets are persistent user data and must survive
- Development, staging, and production use distinct databases. Embedded database
files and uploaded assets are persistent user data and must survive
deployments and additive schema migrations.
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
optional username/password credentials. An account must always retain at
least one usable sign-in method.
optional username/password credentials. An account must always retain at least
one usable sign-in method.
- Anonymous builder drafts may use local storage. Database-backed sharing
requires an account.
- 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
and additive migrations with minimal development-server downtime.
- 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.
## Brand Commitments
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,
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,
clear focus-visible states, screen-reader semantics, sufficient contrast,
reduced-motion preferences, and layouts that work from mobile screens through
4K desktop displays.
reduced-motion preferences, and layouts that work from mobile screens through 4K
desktop displays.
State and meaning cannot depend on color, icons, hover, or motion alone.
Controls need usable touch targets. User identity must support non-ASCII display
+39 -79
View File
@@ -1,8 +1,8 @@
# SHD.build
A SvelteKit community application for *The Division Resurgence*. SHD.build
combines a public game database, loadout builder, shared builds, guides,
account management, and the ISAC data-management console.
A SvelteKit community application for _The Division Resurgence_. SHD.build
combines a public game database, loadout builder, shared builds, guides, account
management, and the ISAC data-management console.
See `docs/architecture.md` for the implementation model.
@@ -17,13 +17,12 @@ Create or update the local database:
npm run db:migrate
```
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
set a different location if needed.
The Infisical `dev` environment sets the local file to
`./data/shd-build-development.db`.
Store custom avatars as metadata-free 256 × 256 WebP files. Set
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
it defaults to `data/avatars`. The same configuration works on Windows and
Linux.
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it
defaults to `data/avatars`. The same configuration works on Windows and Linux.
Seed the initial local accounts once:
@@ -31,9 +30,10 @@ Seed the initial local accounts once:
npm run db:seed-users
```
The seed creates administrator `Aewyn` and standard user `Quartermaster`.
It generates and displays strong temporary passwords once, then stores only
salted Argon2id hashes.
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
generates and displays strong temporary passwords once, then stores only salted
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
account when no administrator exists.
## Develop
@@ -41,30 +41,28 @@ salted Argon2id hashes.
npm run dev
```
## Encrypted environment files
## Infisical secrets
Keep development, staging, and production settings in the committed, encrypted
`.env.development`, `.env.staging`, and `.env.production` files. Keep private
decryption keys only in the ignored `.env.keys` file and back it up in a
password manager.
The committed `.infisical.json` connects the project to the self-hosted
Infisical instance at `https://secrets.jb.codes`. Authenticate the local CLI
once. The CLI uses its configured system vault and can fall back to an encrypted
file vault when the operating-system keyring is unavailable.
```sh
npm run env:decrypt # decrypt all environment files for editing
npm run env:encrypt # encrypt all environment files again
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
npx infisical login --domain=https://secrets.jb.codes
npm run dev
```
The repository pre-commit hook runs `env:stage` and replaces staged plaintext
environment files with encrypted versions. Git has no pre-add hook. Use
`npm run env:stage` instead of directly adding these two files.
Local commands use the `dev` environment. Application configuration lives under
`/app`; the scheduler bearer token lives under `/shared/scheduler`. Staging and
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
`DOTENV_PRIVATE_KEY_PRODUCTION` through their secret manager; do not copy the
keys file to the server.
Staging and production containers authenticate at startup with separate
Universal Auth identities. Gitea stores each client secret, while repository
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
@@ -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
when the container starts; keep them out of the image.
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
and Gitea Actions deployment. A push to `main` validates the application,
builds the candidate image while staging stays online, stops the application
and scheduler for the database backup and migration, then starts the release.
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging and production
Compose topologies. A push to `main` triggers both deployment workflows. Each
workflow validates Infisical access and the candidate image while the current
service stays online, then stops its application and scheduler for backup and
migration.
## Passwordless authentication
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
hostname or a registrable parent domain. Hosted environments use `shd.build`,
which permits credentials on `shd.build` and `dev.shd.build`. Use `localhost`
for direct local development.
which permits credentials on `shd.build`, `stage.shd.build`, and
`dev.shd.build`. Use `localhost` for direct local development.
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
`SMTP_PASSWORD`, and `SMTP_FROM`. Use `SMTP_SECURE=true` on port 465. Use
`false` on port 587 and upgrade with STARTTLS. An account owner activates an
email identity by following a 15-minute, single-use verification link.
Magic links require `SMTP_HOST` and `SMTP_FROM`. `SMTP_PORT` defaults to `587`.
Set `SMTP_SECURE=true` for implicit TLS, normally on port 465; the default
`false` uses STARTTLS. If the SMTP server requires authentication, set both
`SMTP_USER` and `SMTP_PASSWORD`. An account owner activates an email identity by
following a 15-minute, single-use verification link.
## Validate
@@ -98,46 +98,6 @@ npm run check
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
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
+227 -7
View File
@@ -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 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 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 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 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 (
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,
pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
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),
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
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),
provider TEXT NOT NULL CHECK(length(trim(provider))>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,
error_code TEXT,
error_message TEXT,
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
CHECK(
(content_type IN ('comment','avatar') AND revision_id IS NULL)
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL)
(content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS 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(
(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');
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
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 (
id TEXT PRIMARY KEY,
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);
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
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 (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
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 (
'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_by TEXT REFERENCES users(id),
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(
(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
@@ -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);
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
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 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);
@@ -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-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-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-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');
@@ -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-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-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 data_change_proposals (
id TEXT PRIMARY KEY,
@@ -499,6 +718,7 @@ CREATE TABLE IF NOT EXISTS data_change_proposals (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
+28 -6
View File
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2
exit 2
fi
bundle_path=$(readlink -f "$bundle_path")
compose_file="$deployment_directory/compose.production.yml"
deploy_environment="$deployment_directory/.env.deploy"
volume_bundle_path=/app/data/.production-bootstrap-import.json
remove_volume_bundle=false
compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
}
restart_services() {
compose up -d --no-build app scheduler
cleanup() {
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."
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 \
-e ALLOW_PRODUCTION_BOOTSTRAP=true \
-v "$bundle_path:/import/production-bootstrap.json:ro" \
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."
+28 -6
View File
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2
exit 2
fi
bundle_path=$(readlink -f "$bundle_path")
compose_file="$deployment_directory/compose.staging.yml"
deploy_environment="$deployment_directory/.env.deploy"
volume_bundle_path=/app/data/.staging-bootstrap-import.json
remove_volume_bundle=false
compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
}
restart_services() {
compose up -d --no-build app scheduler
cleanup() {
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."
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 \
-e ALLOW_STAGING_BOOTSTRAP=true \
-v "$bundle_path:/import/staging-bootstrap.json:ro" \
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."
+44 -17
View File
@@ -5,25 +5,29 @@ services:
image: ${SHD_IMAGE:-shd-build-production:local}
restart: unless-stopped
init: true
env_file:
- .env.production.runtime
environment:
HOST: 0.0.0.0
PORT: 3000
BODY_SIZE_LIMIT: 6M
SKIP_DEFAULT_USERS: "true"
TDR_DATABASE_PATH: /app/data/shd-build-production.db
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
INFISICAL_ENVIRONMENT: prod
INFISICAL_RUNTIME_SCOPE: app
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
APP_ORIGIN: https://shd.build
ORIGIN: https://shd.build
PASSKEY_RP_ID: shd.build
DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
ports:
- "0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000"
- '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000'
volumes:
- 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:
test:
- CMD
@@ -42,10 +46,23 @@ services:
init: true
command:
- scheduler
env_file:
- .env.production.runtime
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:
app:
condition: service_healthy
@@ -54,3 +71,13 @@ services:
volumes:
shd_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
View File
@@ -5,21 +5,29 @@ services:
image: ${SHD_IMAGE:-shd-build-stage:local}
restart: unless-stopped
init: true
env_file:
- .env.staging.runtime
environment:
HOST: 0.0.0.0
PORT: 3000
BODY_SIZE_LIMIT: 6M
SKIP_DEFAULT_USERS: "true"
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
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
INFISICAL_ENVIRONMENT: staging
INFISICAL_RUNTIME_SCOPE: app
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
ports:
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
- '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000'
volumes:
- 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:
test:
- CMD
@@ -38,10 +46,23 @@ services:
init: true
command:
- scheduler
env_file:
- .env.staging.runtime
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:
app:
condition: service_healthy
@@ -50,3 +71,13 @@ services:
volumes:
shd_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
+111 -6
View File
@@ -1,13 +1,118 @@
#!/bin/sh
set -eu
if [ "${1:-}" = "serve" ]; then
node scripts/db-migrate.mjs
exec node build
project_id=${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID is required}
environment=${INFISICAL_ENVIRONMENT:?INFISICAL_ENVIRONMENT is required}
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
if [ "${1:-}" = "scheduler" ]; then
exec node scripts/scheduler-client.mjs
if [ "$(id -u)" -eq 0 ]; then
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
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
View File
@@ -4,48 +4,154 @@ set -eu
deployment_directory=${1:-/opt/shd-build-production}
source_directory=${2:-}
candidate_image=${3:-}
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image>" >&2
candidate_directory=${4:-}
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
fi
compose_file="$deployment_directory/compose.production.yml"
deploy_environment="$deployment_directory/.env.deploy"
case "$candidate_directory" in
"$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() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
candidate_compose() {
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."
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."
compose stop scheduler app || true
rollback_required=true
candidate_compose stop scheduler app
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'
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."
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)
attempt=0
while [ "$attempt" -lt 30 ]; do
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")
if [ "$status" = "healthy" ]; then
echo "SHD production deployment is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then
break
fi
attempt=$((attempt + 1))
sleep 2
done
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
echo "SHD production deployment is healthy."
rollback_required=false
umask 077
: > "$healthy_marker"
cleanup_candidate
trap - EXIT HUP INT TERM
exit 0
fi
echo "SHD production failed its health check."
compose logs --tail=150 app
canonical_compose logs --tail=150 app scheduler
exit 1
+131 -25
View File
@@ -4,48 +4,154 @@ set -eu
deployment_directory=${1:-/opt/shd-build-stage}
source_directory=${2:-}
candidate_image=${3:-}
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image>" >&2
candidate_directory=${4:-}
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
fi
compose_file="$deployment_directory/compose.staging.yml"
deploy_environment="$deployment_directory/.env.deploy"
case "$candidate_directory" in
"$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() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
candidate_compose() {
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."
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."
compose stop scheduler app || true
rollback_required=true
candidate_compose stop scheduler app
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'
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."
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)
attempt=0
while [ "$attempt" -lt 30 ]; do
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")
if [ "$status" = "healthy" ]; then
echo "SHD staging deployment is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then
break
fi
attempt=$((attempt + 1))
sleep 2
done
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
echo "SHD staging deployment is healthy."
rollback_required=false
umask 077
: > "$healthy_marker"
cleanup_candidate
trap - EXIT HUP INT TERM
exit 0
fi
echo "SHD staging failed its health check."
compose logs --tail=150 app
canonical_compose logs --tail=150 app scheduler
exit 1
+395
View File
@@ -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)];
}),
);
}
+46 -10
View File
@@ -14,7 +14,11 @@ production_environment="$production_directory/.env.deploy"
staging_compose_file="$staging_directory/compose.staging.yml"
staging_environment="$staging_directory/.env.deploy"
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() {
docker compose --env-file "$production_environment" -f "$production_compose_file" "$@"
@@ -22,22 +26,54 @@ production_compose() {
staging_compose() {
docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@"
}
restart_services() {
production_compose up -d --no-build app scheduler || true
staging_compose up -d --no-build app scheduler || true
rm -f "$snapshot_path"
cleanup() {
exit_status=$?
trap - EXIT
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."
production_compose stop scheduler app
production_compose run --rm --no-deps -v "/tmp:/export" app \
node scripts/isac-data-export.mjs "/export/$snapshot_name"
production_app_container=$(production_compose ps --all --quiet app)
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
echo "Stopping staging writers before its automatic full and ISAC safety backups."
staging_compose stop scheduler app
staging_compose run --rm --no-deps -v "/tmp:/import:ro" app \
node scripts/isac-data-import.mjs "/import/$snapshot_name" --confirm-replace-isac-data
staging_app_container=$(staging_compose ps --all --quiet app)
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."
+44 -27
View File
@@ -1,14 +1,20 @@
# Community content moderation
SHD.build applies one moderation lifecycle to public comments, profile
biographies, custom avatars, guides, and build descriptions. The shared server
service lives in `src/lib/server/community-moderation.ts`. Content save actions
must use that service before publication.
biographies, custom avatars, and Guides. The shared server service lives in
`src/lib/server/community-moderation.ts`. Content save actions must use that
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
The moderation service combines local rules with the configured provider
result:
The moderation service combines local rules with the configured provider result:
- Clean content publishes without human review when automatic publication is
enabled.
@@ -18,8 +24,8 @@ result:
enabled.
- An allow term can suppress the matching local term rule. It cannot override a
provider flag or threshold.
- A user report sends visible content to Oversight. One report does not hide
the content because reports can be mistaken or abusive.
- A user report sends visible content to Oversight. One report does not hide the
content because reports can be mistaken or abusive.
The service records every automated scan in `content_moderation_scans`.
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
- `removed`: unavailable after author removal or an Oversight decision
Long-form content uses immutable pending revisions. Profile and guide edits that
need review go into `moderated_content_revisions`; the public row keeps its last
approved text. Approval copies the reviewed revision into the public row.
Rejection preserves the revision and review evidence without publishing it.
Long-form content uses immutable pending revisions. Profile biographies keep
text candidates in `moderated_content_revisions`. Guides use the complete
structured snapshot in `article_revisions`, including title, deck, document,
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
or fail-closed scan error creates an `avatar_reviews` item and is unavailable
News articles do not use automated content moderation. The `news.manage`
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
review item, but leaves an already-published avatar visible while Oversight
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.
Site Controls requires `site.settings.manage`. Community Oversight handles text
decisions through the existing comment and profile permissions. Avatar
Oversight handles custom images through `avatars.review`.
decisions through the existing comment and profile permissions. Avatar Oversight
handles custom images through `avatars.review`.
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
@@ -78,10 +91,14 @@ the content and review records change.
## User reports
Authenticated Agents can report visible content they do not own. The report
control must identify the content type and stable content ID, validate that the
content remains public, and create a pending `user_content_reports` row.
Duplicate pending reports from the same Agent for the same content are rejected.
Authenticated Agents can report visible comments, biographies, and avatars they
do not own. Registered and anonymous visitors can report visible Guides. A Guide
report records the exact published `article_revision_id`; a decision cannot hide
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
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
Guide authoring, build descriptions, and later public text fields must complete
this checklist:
Guide authoring and later public text fields must complete this checklist:
1. Normalize and validate the submitted text before scanning.
2. Call `moderateCommunityText` with a supported content type.
@@ -98,14 +114,15 @@ this checklist:
4. Publish only an `approved` result.
5. Store held long-form text as an immutable revision so the last approved
version remains public.
6. Call `recordCommunityModerationScan` with the stable content ID, author, hash,
policy version, provider evidence, and local-rule matches.
6. Call `recordCommunityModerationScan` with the stable content ID, exact
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.
8. Add the shared report control to the public rendering.
9. Delay follower, owner, or reply notifications until the content becomes
public.
10. Add approval, rejection, report dismissal, author notification, and audit
tests.
tests.
11. Confirm every public query selects approved content explicitly.
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
and policy version so reviewers can interpret older decisions. Test threshold
changes against representative Division terminology before activation,
including weapon names, damage descriptions, kills, status effects, and build
strategy language.
changes against representative Division terminology before activation, including
weapon names, damage descriptions, kills, status effects, and build strategy
language.
Database migrations that add moderation states or revision storage follow the
repository database-preservation procedure: stop this project's development
+230 -103
View File
@@ -2,8 +2,8 @@
SHD.build runs in a Node 24 container through the official SvelteKit Node
adapter. The image includes application code, migration and notification
scripts, and native runtime dependencies. Environment files, databases,
backups, and user uploads remain outside the image.
scripts, the Infisical CLI, and native runtime dependencies. Databases, backups,
user uploads, and Universal Auth bootstrap credentials remain outside the image.
## Persistent data
@@ -16,8 +16,8 @@ mounted at `/app/data`. It contains:
- uploaded game-data icons under `game-icons/`.
Keep this volume during routine deployments. `docker compose down` without
`--volumes` preserves it. Before a manual backup or restore, stop the `app`
and `scheduler` services because both access the embedded database.
`--volumes` preserves it. Before a manual backup or restore, stop the `app` and
`scheduler` services because both access the embedded database.
## Staging environment
@@ -29,25 +29,74 @@ Staging defaults to:
- Passkey relying-party ID: `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
npm run env:decrypt:staging
# Fill in Discord and SMTP values.
npm run env:encrypt:staging
```text
TURNSTILE_SITE_KEY=<public site key>
TURNSTILE_SECRET_KEY=<server verification secret>
```
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
allowed OAuth redirect URIs. Point the staging DNS record at the deployment
host and configure the host's TLS reverse proxy to forward
`stage.shd.build` to port `5569` on the staging host. Docker publishes this
port on the host interfaces so a containerized reverse proxy or tunnel can
reach it; firewall the port to trusted LAN/tunnel sources when the host is
reachable from other networks.
allowed OAuth redirect URIs. Point the staging DNS record at the deployment host
and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
`5569` on the staging host. Docker publishes this port on the host interfaces so
a containerized reverse proxy or tunnel can reach it; firewall the port to
trusted LAN/tunnel sources when the host is reachable from other networks.
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
file. Gitea Actions holds the decryption key; Git and the container image do
not.
The trusted staging runner writes separate mode-`0600` Universal Auth client-ID
and client-secret files for the app and scheduler. Compose mounts each pair only
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
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 requires an `act_runner`. Register a trusted runner on the
staging host from the repository's **Settings → Actions → Runners** page. The
host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account
must be able to use Docker and write to the deployment directory.
Gitea Actions requires an `act_runner`. Register a trusted runner on the staging
host from the repository's **Settings → Actions → Runners** page. The host needs
Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able
to use Docker and write to the deployment directory.
Use runner labels that provide an isolated validation job and a host deployment
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`:
1. Add repository secret `DOTENV_PRIVATE_KEY_STAGING`, using the matching value
from the ignored local `.env.keys`.
2. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and
`INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`.
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`.
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
4. Ensure Actions are enabled and the `stage-deploy` runner is online.
4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
5. Ensure Actions are enabled and the `stage-deploy` runner is online.
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
dispatches. The trusted staging runner builds each commit-tagged image locally,
so the workflow needs no registry credentials. Gitea 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.
dispatches. The trusted staging runner builds each image locally with a tag that
contains the commit, workflow run, and attempt, so the workflow needs no
registry credentials and a retry cannot overwrite the active image. Gitea
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
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;
3. stops only the staging application and notification scheduler;
4. creates a timestamped JSON backup if the staging database exists;
5. applies additive, repeatable migrations;
6. starts the application and scheduler with the new image;
7. waits for `/api/health` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails.
3. starts one-off app and scheduler containers to verify Infisical access and
validate the candidate environment;
4. stops only the staging application and notification scheduler;
5. creates a timestamped JSON backup if the staging database exists;
6. applies additive, repeatable migrations in a one-off container;
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
remove records that use retired data formats when a release explicitly ends
compatibility. The deployment backup remains the recovery source for those
records; the application does not repair or reinterpret them at runtime.
The scheduler calls a bearer-authenticated endpoint on the application service
once per minute. Scheduled message delivery therefore uses the web process's
existing embedded-database connection instead of opening the locked database
from a second process. The endpoint runs automated system messages, Oversight
consolidation, and email digest checks and is not published through a host port.
records; the application does not repair or reinterpret them at runtime. The
scheduler calls a bearer-authenticated endpoint on the application service once
per minute. Scheduled message delivery therefore uses the web process's existing
embedded-database connection instead of opening the locked database from a
second process. The endpoint runs automated system messages, Oversight
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
@@ -131,8 +215,8 @@ for a database that does not contain the row:
- News, Database, Builds, Guides, and Builder: enabled
The migration uses an insert-if-missing seed. Later deployments preserve the
operator's settings, and configuration changes take effect without a restart.
A full environment bootstrap copies the source row. Canonical ISAC game-data
operator's settings, and configuration changes take effect without a restart. A
full environment bootstrap copies the source row. Canonical ISAC game-data
imports leave the target environment's launch settings unchanged.
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
remain unavailable.
Keep Coming Soon enabled while preparing production. Section controls can be
set in advance because the launch gate takes precedence for signed-out traffic.
Set Account Creation to Disabled if prelaunch access must remain limited to
existing identities. Switch the Launch Gate to Public Launch after verification.
Disabled sections remain absent from navigation and unavailable by direct URL
for every user, including authenticated agents. Authentication bypasses only
the Coming Soon gate.
Keep Coming Soon enabled while preparing production. Section controls can be set
in advance because the launch gate takes precedence for signed-out traffic. Set
Account Creation to Disabled if prelaunch access must remain limited to existing
identities. Switch the Launch Gate to Public Launch after verification. Disabled
sections remain absent from navigation and unavailable by direct URL for every
user, including authenticated agents. Authentication bypasses only the Coming
Soon gate.
Turn Coming Soon on to restore the promotional gate. Site-control changes append
`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
```
The command writes a timestamped, ignored JSON bundle under `data/backups/`.
The bundle includes user records and password hashes. Transfer it over an
encrypted channel, keep it out of Git, then copy it to the staging host and run:
The command writes a timestamped, ignored JSON bundle under `data/backups/`. The
bundle includes user records and password hashes. Transfer it over an encrypted
channel, keep it out of Git, then copy it to the staging host and run:
```sh
/opt/shd-build-stage/bootstrap-staging.sh \
/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
target, verifies that staging is unused, imports the entire development
database and uploads, and restarts both services. Delete the transfer copy from
the staging host after verifying the imported record counts and critical
entities. The copy under the local ignored backup directory remains the
recovery source.
The bootstrap flow requires both services to stop, copies the bundle into the
private data volume, assigns it to the runtime UID, creates a safety backup,
verifies that staging is unused, imports the entire development database and
uploads, deletes the volume copy, and restarts both services. Delete the host
transfer copy after verifying the imported record counts and critical entities.
The copy under the local ignored backup directory remains the recovery source.
Because this is a full initial clone, development session tokens and account
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`
- Persistent volume: `shd-build-production-data`
Routine pushes leave production data unchanged. The production deploy stops its
application and scheduler, creates a timestamped JSON backup, applies repeatable
migrations, restarts, and verifies database health. Keep
`docker compose down --volumes` away from production.
Routine pushes preserve production data unless an included migration removes a
retired prototype format. The production deploy stops its application and
scheduler, creates a timestamped JSON backup, applies repeatable migrations,
restarts, and verifies database health. Keep `docker compose down --volumes`
away from production.
Add these repository Action secrets:
- `DOTENV_PRIVATE_KEY_PRODUCTION`, matching the ignored local `.env.keys`
- `SCHEDULER_TOKEN_PRODUCTION`, a long random value used only by the internal
scheduler endpoint
- `INFISICAL_PRODUCTION_APP_CLIENT_SECRET`
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET`
Add the matching repository variables:
- `INFISICAL_PRODUCTION_APP_CLIENT_ID`
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID`
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
`/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:
```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,
then enter it directly in Gitea's repository-secret form. Do not commit it or
reuse the staging scheduler token.
Store a separate production `SCHEDULER_TOKEN` under Infisical path
`/shared/scheduler`. The application and scheduler fetch the same value from
that path. Do not reuse the staging token.
### 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
```
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 transfer copy after verification.
The bootstrap script requires both services to stop, stages the bundle in the
private data volume with runtime-only access, and removes that copy after the
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
@@ -312,29 +426,29 @@ notifications, settings, and audit history untouched.
### Pull production ISAC data into staging
Manually run **Replace staging ISAC data from production** in Gitea Actions.
The workflow exports a consistent production snapshot, restarts production,
then stops staging and imports it. The staging importer creates full and
ISAC-only safety backups before replacement. Deployments do not run this flow.
Manually run **Replace staging ISAC data from production** in Gitea Actions. The
workflow exports a consistent production snapshot, restarts production, then
stops staging and imports it. The staging importer creates full and ISAC-only
safety backups before replacement. Deployments do not run this flow.
### Promote managed images without replacing other data
Image Library assets use stable IDs and can be promoted additively from local
development. Export a bundle after finishing local uploads and metadata edits:
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header.
Transparent icon uploads skip remove.bg. Opaque sources use its preview output,
then pass through local trimming, white normalization, WebP conversion, and
storage.
Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical
environment that accepts ISAC uploads. The server sends it only in the
`X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use
its preview output, then pass through local trimming, white normalization, WebP
conversion, and storage.
The application stores a remove.bg capacity snapshot and refreshes it from the
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
interval and defaults to six hours. Low-capacity alerts require both remaining
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
calls. Alerts are mandatory ISAC notifications for active Administrators and
are deduplicated for each warning or critical episode.
calls. Alerts are mandatory ISAC notifications for active Administrators and are
deduplicated for each warning or critical episode.
```sh
npm run images:export
@@ -348,19 +462,33 @@ embedded database is unlocked, and run the merge using the target image:
```sh
# Staging
cd /opt/shd-build-stage
docker compose -f compose.staging.yml stop scheduler app
docker compose -f compose.staging.yml run --rm \
-v /secure/path:/import:ro app \
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
docker compose -f compose.staging.yml up -d app scheduler
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.staging.yml create app
app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app)
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
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
cd /opt/shd-build-production
docker compose -f compose.production.yml stop scheduler app
docker compose -f compose.production.yml run --rm \
-v /secure/path:/import:ro app \
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
docker compose -f compose.production.yml up -d app scheduler
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.production.yml create app
app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app)
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
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
@@ -368,12 +496,11 @@ adds new stable IDs, skips identical assets, and blocks conflicting records.
After reviewing an intentional metadata or file replacement, add
`--update-existing`. Imported changes append `image.imported` or
`image.import_updated` audit events. Remove the transferred bundle after
verifying both environments because it may contain unpublished visual assets
and creator identifiers.
verifying both environments because it may contain unpublished visual assets and
creator identifiers.
The `.env.staging` and `.env.production` npm import commands are intended for
maintenance performed directly in an environment where those database and
storage paths are mounted:
The staging and production npm import commands fetch `/app` from Infisical. Run
them only where the target database and storage paths are mounted:
```sh
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
+1011 -880
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -26,7 +26,14 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **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
round to load. A canceled reload does not count. A weapon that loads one
round at a time produces one completed-reload event for each round.
+403 -260
View File
@@ -5,24 +5,35 @@ The team builds the project in vertical slices.
## Application stack
- SvelteKit 2 / Svelte 5 with TypeScript
- Embedded Turso Database via `@tursodatabase/database` for durable application data
- Auth.js-compatible server authentication with Discord OAuth and credentials providers
- Argon2id password hashing, server-side sessions, CSRF protection, and role checks
- Embedded Turso Database via `@tursodatabase/database` for durable application
data
- Auth.js-compatible server authentication with Discord OAuth and credentials
providers
- Argon2id password hashing, server-side sessions, CSRF protection, and role
checks
- Browser `localStorage` only for anonymous build drafts
## Product boundaries
- Public: browse, sort, and view published builds and guides.
- Anonymous: create and edit local drafts.
- Authenticated: publish, rate, comment, favorite, copy, and manage owned builds.
- Authenticated: publish, rate, comment, favorite, copy, and manage owned
builds.
- Editor: manage builder source data.
- Admin: manage users, roles, source data, and audit history.
Server code checks authorization for each mutation. UI visibility has no effect
on server authorization.
Weapon and gear range calculations must preserve decimal endpoints
and follow the tested progression rules in
Builder and shared Build pages currently expose only deterministic totals from
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
encounter assumptions, activation profiles, rotations, and simulation remain
planned. Explicit `COMING SOON` panels identify those player-facing
capabilities.
Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
compounded. The presentation layer truncates game-matching integer values.
@@ -49,9 +60,9 @@ Builder. Schema migration creates the row with Coming Soon enabled, account
creation enabled, and all five sections enabled. The application caches the row
for five seconds and clears the process cache after an operator saves changes.
Agents with `site.settings.manage` edit the row from ISAC → System
Configuration → Site Controls. The save action requires an explicit value for
all seven controls and a confirmation token. A successful update records
Agents with `site.settings.manage` edit the row from ISAC → System Configuration
→ Site Controls. The save action requires an explicit value for all seven
controls and a confirmation token. A successful update records
`system_configuration / site_access.updated` with the operator, target row, and
complete before/after snapshots.
@@ -84,22 +95,143 @@ closed.
## Community content moderation
Comments, profile biographies, custom avatars, guides, and build descriptions
share the moderation lifecycle documented in
[`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local rules and the configured
provider classify each submission before publication. Clean submissions can
publish without human work. Flagged submissions and fail-closed provider errors
enter the appropriate Oversight queue.
Comments, profile biographies, custom avatars, and Guides share the moderation
lifecycle documented in [`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local
rules and the configured provider classify each submission before publication.
Clean submissions can publish without human work. Flagged submissions and
fail-closed provider errors enter the appropriate Oversight queue.
Agents may report visible content they do not own. Reports enter Oversight
without hiding the content. Human decisions require a reason and append an
audit event. Long-form edits that need review use immutable pending revisions
so the last approved biography, guide, or description remains public.
without hiding the content. Human decisions require a reason and append an audit
event. Long-form edits that need review use immutable pending revisions so the
last approved biography or Guide remains public.
ISAC Site Controls owns provider behavior, content scopes, thresholds, link and
PII checks, and allowed or banned terms. API credentials remain in server
environment configuration.
## Guides and News articles
`articles` stores the stable identity, kind, owner, immutable slug, visibility,
and published or pending revision pointers for Guides and News. Authors edit one
mutable `article_drafts` row with optimistic version checks. Publication creates
an immutable `article_revisions` snapshot. Public readers and search load only
the revision named by `articles.published_revision_id`.
Guides require OpenAI moderation before an unlisted or public revision can
publish. The moderation request includes the title, deck, document text, image
alternative text and captions, cover image, and inline images. A failed provider
request stays in Guide Oversight. Clean revisions may publish automatically;
flagged revisions preserve the previous public revision while Oversight decides.
News bypasses this lifecycle because `news.manage` is the editorial gate. News
comments still use community moderation.
### Ubisoft News syndication
The Ubisoft News importer reads the English-language Division Resurgence feed
from Ubisoft Nimbus. It stores each Nimbus item ID as the stable external ID and
publishes the source title, excerpt, canonical Ubisoft link, and validated
Ubisoft-hosted thumbnail. SHD.build does not copy the article body or image
file. A bounded same-origin endpoint proxies each thumbnail so opening the News
listing does not send visitor metadata to Ubisoft. News cards link to the
canonical Ubisoft article. Imported articles use Ubisoft's original publication
timestamp rather than the import time.
`external_news_sources` maps each Nimbus ID to one SHD.build article. A SHA-256
content hash covers the normalized title, excerpt, canonical URL, and source
publication timestamp. An unchanged hash updates `last_seen_at` without creating
a revision. Thumbnail changes update the audited source metadata without
creating an article revision. A changed content hash creates and publishes an
immutable revision. The importer does not delete or unpublish an article when
Nimbus omits it from a later feed, and it does not restore an article that an
editor deleted. The Newsroom treats source-linked articles as read-only so local
edits cannot retain Ubisoft attribution. The importer defers future-dated items
until their source publication time.
The seeded, non-public `Ubisoft Official News` service identity owns imported
articles and acts on their audit events. A first import records
`editorial_content / news.imported`; a later content sync records
`editorial_content / news.import_synced`. Audit snapshots identify the provider,
Nimbus ID, canonical URL, thumbnail metadata, source timestamp, and resulting
revision.
The existing bearer-authenticated scheduler endpoint invokes the importer. A
successful run sets the next check one hour later. The importer stores the last
attempt, last success, next check, consecutive failure count, and bounded last
error in provider state. Failures retry after 15 minutes with exponential
backoff capped at six hours. A successful run clears the error and failure
count.
The article document is a versioned, strictly validated Tiptap JSON envelope.
The application stores no author-supplied HTML and renders accepted nodes with
Svelte components. `article_revision_build_embeds` supports several Builds per
Guide and several Guides per Build. Each relation records a bounded publication
snapshot. Authors may embed any Build they own; another Agent's Build must be
public and becomes unavailable in the Guide if its source stops being public.
Guide taxonomy combines fixed activity and difficulty terms with active
Specializations, Focuses, and weapon categories. `article_search` stores the
published title, deck, body text, and tags. Weighted token scoring prioritizes
title, deck, tags, then body because the embedded Turso engine does not ship the
FTS5 module. Search rows exist only for current public revisions.
Registered and anonymous visitors may rate a Guide from 1 through 5. Anonymous
ratings and Guide reports use a random HTTP-only browser token; the database
stores only its domain-separated hash. Repeated anonymous activity requires a
Turnstile result. Guide reports reference the exact published revision so a
report against an older revision cannot hide newer work.
The Builder stores loadout data and displays deterministic equipped-item totals.
It has no Guide body. Shared Build pages use the same player-facing calculation
boundary. An owned Build page links to `/guides/new?build=<id>`, where the
server verifies ownership and initializes a removable Build block in a new
private Guide.
### Discord-compatible Build sharing experiment
Persisted public and unlisted Builds expose absolute Open Graph metadata for
their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata
reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so
Discord and other link unfurlers can render a Build summary. Private Builds set
`noindex,nofollow`, expose no crawler image, and provide no usable sharing
control.
The preview endpoint derives its image from canonical Build facts and the
current known-stat summary in the shared Build projection. It includes the Build
title, author, visibility, direct URL, equipped weapon names, core totals, and
up to three supporting Attributes. The renderer performs no external fetches. It
normalizes and bounds every text field by grapheme count, escapes text before
inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG
with Sharp. Production installs DejaVu Sans from the Debian package and the
renderer names that fixed family explicitly.
The endpoint hashes a versioned serialization of the image input for its bounded
in-process render cache and derives the HTTP ETag from the resulting PNG bytes.
A bounded request queue limits concurrent database projections and Sharp work.
Responses require cache revalidation so a visibility change to Private blocks
the next request instead of leaving a stale response window. Matching
conditional requests receive `304 Not Modified`. Private, missing, and
inaccessible Builds receive no preview. Unlisted images send the
`X-Robots-Tag: noindex` response header.
For eligible Builds, the Build Uplink invokes the native Web Share API as the
primary action when the browser supports it. An explicit Copy Link action
remains available for every public or unlisted persisted Build. This experiment
does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or
store Discord tokens or webhooks. Discord receives only a link that an Agent
chooses to share and may fetch the public metadata and same-origin preview.
## Public data corrections and discussion
Every public database card links to its canonical ISAC record with an exact
`item` ID and offers a moderated Community Discussion. Authenticated Agents can
post notes and replies; Community Oversight may pin one visible comment as the
record's Community Note. A public correction opens the same ISAC editor used by
data operators. Contributor-mode submissions create a proposal rather than
changing canonical data. Contributors can attach an optional bounded rationale;
the rationale is retained with the proposal and visible to Data Oversight, but
does not become part of the canonical action replay.
## Increment plan
1. Product shell and anonymous local-draft builder.
@@ -109,10 +241,11 @@ environment configuration.
5. Publishing, discovery, ratings, comments, favorites, and copy flow.
6. Rich build guides and moderation.
7. Admin editors, validation, audit log, and data import/export.
# Administrator bootstrap
The authentication service promotes the first authenticated user to `admin` if the
database does not contain an administrator. Promotion uses one conditional
The authentication service promotes the first authenticated user to `admin` if
the database does not contain an administrator. Promotion uses one conditional
database update so concurrent first sign-ins cannot both become administrators.
Once an administrator exists, later sign-ins never alter roles.
@@ -134,8 +267,8 @@ npm run db:backup:production
```
The scripts write versioned JSON documents beneath `data/backups/` by default.
Git ignores that directory because backups contain private user data,
including password hashes.
Git ignores that directory because backups contain private user data, including
password hashes.
To restore, first create an empty database and apply the schema. Then run:
@@ -144,18 +277,18 @@ npm run db:restore -- data/backups/<backup-file>.json
```
The restore operation refuses to import into any database containing records.
This guard prevents accidental merging or overwriting of contributor data.
Stop the application before backup or restore so the embedded database file is
not locked by another process.
This guard prevents accidental merging or overwriting of contributor data. Stop
the application before backup or restore so the embedded database file is not
locked by another process.
## Development database maintenance
Treat the development database as persistent data. Stop this project's
development server before backup, export, restore, migration, or other work
requiring exclusive access. Create and verify a JSON backup, perform the
prepared operation, verify schema state and critical record counts, then
restart and health-check the application. Prefer additive, repeatable
migrations and retain the verified backup if maintenance fails.
prepared operation, verify schema state and critical record counts, then restart
and health-check the application. Prefer additive, repeatable migrations and
retain the verified backup if maintenance fails.
Production and staging remain shared persistent environments and retain the
deployment, backup, synchronization, and maintenance safeguards documented in
@@ -165,22 +298,22 @@ deployment, backup, synchronization, and maintenance safeguards documented in
Every successful ISAC mutation appends an event to `audit_log` through the
shared server audit utility. Events identify the operator, operational category,
specific action, target entity, timestamp, and useful before/after evidence.
The audit registry is read-only and requires `audit.view`; the seeded Auditor
role grants that permission without granting mutation capabilities.
specific action, target entity, timestamp, and useful before/after evidence. The
audit registry is read-only and requires `audit.view`; the seeded Auditor role
grants that permission without granting mutation capabilities.
Audit history is immutable. The server implements “Revert” as a new compensating
mutation rather than deletion or modification of the original event:
1. The operator must hold `audit.revert` in addition to any other access.
2. The reversion registry must support the original action and the event must contain a usable
before-snapshot.
2. The reversion registry must support the original action and the event must
contain a usable before-snapshot.
3. The UI requires a second, explicit confirmation.
4. The server reloads the target and compares it with the original
after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
work and blocks automatic compensation.
5. The server revalidates referenced roles, permissions, and relationships before
restoration. Restoring administrator authority also requires full
5. The server revalidates referenced roles, permissions, and relationships
before restoration. Restoring administrator authority also requires full
administrative access.
6. The server restores the previous state and appends an `audit.reverted` event
whose target is the original audit-event ID.
@@ -189,47 +322,48 @@ mutation rather than deletion or modification of the original event:
# Moderated game-data contributions
ISAC data access uses `data.view`, `data.propose`, `data.manage`, and
`data.approve`. Data Viewers can inspect registries. Data Contributors can submit
the same editor forms, but the server serializes each request into
`data.approve`. Data Viewers can inspect registries. Data Contributors can
submit the same editor forms, but the server serializes each request into
`data_change_proposals` instead of mutating canonical tables. Data Oversight
reviews the queue and must confirm approval or rejection.
Approval replays the stored form through its original SvelteKit server action.
This preserves canonical validation, relational updates, icon processing, and the
normal game-data audit event. Proposal creation and review decisions also produce
separate `data_moderation` audit events. Failed canonical validation marks a
proposal as failed without changing live data. Pending counts appear alongside
each data registry in ISAC so collaborators can see concurrent suggestions.
This preserves canonical validation, relational updates, icon processing, and
the normal game-data audit event. Proposal creation and review decisions also
produce separate `data_moderation` audit events. Failed canonical validation
marks a proposal as failed without changing live data. Pending counts appear
alongside each data registry in ISAC so collaborators can see concurrent
suggestions.
The protected seeded roles are Data Viewer, Data Contributor, Data Editor, and
Data Oversight. Data Editor retains direct-write access for trusted operators;
Data Oversight combines direct-write access with queue review. New ISAC data
modules must use the shared moderation interception path and audit both the final
mutation and proposal lifecycle.
modules must use the shared moderation interception path and audit both the
final mutation and proposal lifecycle.
Mechanic programs remain part of their owning source record. Talent, OS
Protocol, Skill Chip, and Specialization proposals carry the complete typed
program with the rest of the source payload. Oversight reviews and approves
that program through the source module's canonical validation path. There is
no standalone Trigger proposal module or pending Trigger dependency.
program with the rest of the source payload. Oversight reviews and approves that
program through the source module's canonical validation path. There is no
standalone Trigger proposal module or pending Trigger dependency.
Each authenticated, active registered agent receives `isac.access`,
`data.view`, and `data.propose` as baseline permissions in the authorization
layer. The authorization layer grants this baseline without a removable role
assignment, so existing users and each account-creation method receive the same
access. Registered agents can inspect live source data and submit
moderated proposals, but cannot mutate canonical records, review the
queue, or access unrelated ISAC operations without additional permissions.
Unsupported destructive identity operations, account deletion, incomplete
snapshots, stale targets, and events whose dependencies no longer exist are
marked non-reversible or rejected. Recovery for those cases requires a
specialized workflow rather than inferred database writes.
Each authenticated, active registered agent receives `isac.access`, `data.view`,
and `data.propose` as baseline permissions in the authorization layer. The
authorization layer grants this baseline without a removable role assignment, so
existing users and each account-creation method receive the same access.
Registered agents can inspect live source data and submit moderated proposals,
but cannot mutate canonical records, review the queue, or access unrelated ISAC
operations without additional permissions. Unsupported destructive identity
operations, account deletion, incomplete snapshots, stale targets, and events
whose dependencies no longer exist are marked non-reversible or rejected.
Recovery for those cases requires a specialized workflow rather than inferred
database writes.
# System metrics
ISAC uses `/isac` as its canonical route namespace. The server
redirects legacy `/admin` URLs to their matching `/isac` location so old
notifications and external bookmarks remain usable.
ISAC uses `/isac` as its canonical route namespace. The server redirects legacy
`/admin` URLs to their matching `/isac` location so old notifications and
external bookmarks remain usable.
The read-only `/isac/metrics` route requires `metrics.view` and collects a
point-in-time process-local health snapshot. Node and operating-system APIs
@@ -241,10 +375,10 @@ provide application record volumes.
The interface refreshes on demand and may poll every ten seconds while the
operator keeps auto-refresh enabled. CPU and event-loop values are deltas from
the previous server-side sample, so the first sample and samples separated by
long gaps provide direction rather than a monitoring-grade time series. Production alerting
and historical analysis should use a dedicated external telemetry
system. This surface omits secrets, environment-variable values,
and full database paths.
long gaps provide direction rather than a monitoring-grade time series.
Production alerting and historical analysis should use a dedicated external
telemetry system. This surface omits secrets, environment-variable values, and
full database paths.
The same surface reports remove.bg paid credits and free API calls as separate
capacity pools. The scheduler reconciles the account balance no more often than
@@ -252,8 +386,8 @@ the configured interval, while successful image processing applies the
provider's `X-Credits-Charged` value to the persisted estimate. Warning and
critical transitions create one alert episode with a unique delivery for each
active Administrator. A later critical transition may alert again; repeated
scheduler runs within the same episode do not. Provider errors preserve the
last known balance and never expose the API key or raw response body.
scheduler runs within the same episode do not. Provider errors preserve the last
known balance and never expose the API key or raw response body.
# Weapon category bonuses
@@ -266,8 +400,8 @@ Weapon records do not own copies of these rows. Both builder catalog loaders
attach the matching category profile when they hydrate a weapon. Exotic weapons
retain an underlying category and inherit the same profile as a standard weapon
of that type. The combat summary preserves the existing equipped-weapon
semantics: each equipped weapon contributes its inherited profile to the
Agent's accumulated Attributes.
semantics: each equipped weapon contributes its inherited profile to the Agent's
accumulated Attributes.
ISAC exposes one category-profile editor above the weapon registry. Operators
may change the three positive values, while the Attribute identities and order
@@ -286,14 +420,14 @@ An exotic weapon references one active exotic talent through
`weapons.exotic_talent_id`. The weapon editor lists registered exotic talents
and identifies talents assigned to another weapon. Server validation rejects
standard talents, inactive talents, and talents already assigned elsewhere.
Changing or clearing a weapon relationship leaves the previous talent active
and available for another assignment.
Changing or clearing a weapon relationship leaves the previous talent active and
available for another assignment.
Tier I and its modeled mechanic are required when a contributor saves an
exotic talent. Tier II may remain undocumented. An undocumented Tier II has no
Tier I and its modeled mechanic are required when a contributor saves an exotic
talent. Tier II may remain undocumented. An undocumented Tier II has no
`talent_tiers` row or conditional-effect value variant. A later Talent Registry
edit adds the Tier II projection and mechanic values without changing the
weapon reference.
edit adds the Tier II projection and mechanic values without changing the weapon
reference.
# OS Protocol progression
@@ -309,22 +443,22 @@ attribute-type combination, with its three fixed stat/value pairs in
`os_protocol_secondary_effects`. Every matching protocol inherits that profile;
secondary values are not copied onto individual protocol records.
The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, while
talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
Server actions validate all relationships and append the full owned graph to
the existing `game_data` audit history.
The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50,
while talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
Server actions validate all relationships and append the full owned graph to the
existing `game_data` audit history.
### Weapon Mods and tiered weapon slots
`weapon_mod_slots` records each weapon's available slot vocabulary per tier.
The editor captures one shared set for T1/T2 and the single shared slot omitted
at T0, then expands that input into the normalized per-tier rows. Operators may
`weapon_mod_slots` records each weapon's available slot vocabulary per tier. The
editor captures one shared set for T1/T2 and the single shared slot omitted at
T0, then expands that input into the normalized per-tier rows. Operators may
save partial rows when the in-game configuration is unavailable. ISAC derives
`CONFIG REQUIRED` until every available tier contains four unique slots at T0
or five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
compatible base weapon categories through
`weapon_mod_compatible_categories`; eligibility requires both a category match
and the slot to exist on the equipped weapon at its selected tier.
`CONFIG REQUIRED` until every available tier contains four unique slots at T0 or
five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
compatible base weapon categories through `weapon_mod_compatible_categories`;
eligibility requires both a category match and the slot to exist on the equipped
weapon at its selected tier.
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
regular category compatibility. The builder treats them as permanent equipment
@@ -336,62 +470,60 @@ interpolation.
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
builder-time primary choice. `weapon_mod_quality_values` stores the shared
level-1 and level-20 primary roll endpoints for each quality; primary values
use linear interpolation from levels 1–20.
level-1 and level-20 primary roll endpoints for each quality; primary values use
linear interpolation from levels 1–20.
`weapon_mod_secondary_options` is the shared pool from which a build selects
two distinct secondary attributes. Every option owns a minimum and maximum at
each quality through `weapon_mod_secondary_quality_values`; secondary
availability is not tied to a numbered slot in canonical data. Mod Effects are
triggerless, hold one or two attribute references, and store fixed per-quality
values in `weapon_mod_effect_quality_values`.
`weapon_mod_secondary_options` is the shared pool from which a build selects two
distinct secondary attributes. Every option owns a minimum and maximum at each
quality through `weapon_mod_secondary_quality_values`; secondary availability is
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
one or two attribute references, and store fixed per-quality values in
`weapon_mod_effect_quality_values`.
### Gear Mods and tier slot capacity
Gear Mod definitions are standard-quality records only; there is no exotic
Gear Mod branch. Every definition supplies primary roll ranges for Standard,
Gear Mod definitions are standard-quality records only; there is no exotic Gear
Mod branch. Every definition supplies primary roll ranges for Standard,
Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
those endpoints use linear interpolation. A build instance selects one primary attribute
from Firepower, Toughness, Engineering, Health, or Armor.
those endpoints use linear interpolation. A build instance selects one primary
attribute from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration:
T0 gear has one Gear Mod slot; T1 and T2 gear have two.
four qualities. Gear tier determines capacity without per-item configuration: T0
gear has one Gear Mod slot; T1 and T2 gear have two.
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
`gear_mod_secondary_quality_values` tables store canonical data.
Successful mutations use `gear_mod.created`, `gear_mod.updated`,
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
submissions use the `gearmods` proposal module and remain non-canonical until
approved by Oversight.
`gear_mod_secondary_quality_values` tables store canonical data. Successful
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
proposal module and remain non-canonical until approved by Oversight.
# Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one
2-piece bonus, distinct primary and secondary stat-option pools, and one
3-piece Talent with a typed conditional rule. Primary and secondary options
reference active Attributes that define Skill Chip minimum and maximum roll
values. A configured chip rolls one primary and two distinct secondary stats
from those pools.
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
Talent with a typed conditional rule. Primary and secondary options reference
active Attributes that define Skill Chip minimum and maximum roll values. A
configured chip rolls one primary and two distinct secondary stats from those
pools.
Each active Skill Chip definition belongs to one active specialization ability
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
three active definitions. The save and restore actions enforce that capacity,
and the schema requires the foreign key for each row. A specialization focus is
builder-ready only when each of its three
assigned abilities has all three active Skill Chip definitions. Skill Chip
mutations refresh readiness for focuses that reference both the previous and
resulting ability.
builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through
three, a configured minimum and maximum resolve as
the interpolation step. For `h` High-end chips, where `h` is zero through three,
a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging.
The `game_data` audit stream captures Skill Chip mutations, owned relationships,
and safe icon metadata.
previews all four states, preventing duplicated tier data from diverging. The
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
safe icon metadata.
# Mechanic programs and loadout state
@@ -401,10 +533,11 @@ Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The
`source_type` and `source_id` pair identifies that owner. No standalone Trigger
record or shared Trigger relationship participates in execution.
The builder supplies normalized timeline events to each equipped mechanic.
`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the
mechanic's moving and sprinting scenario controls select that state, with
sprinting taking precedence. Enabled Skill activation profiles emit
The mechanic runtime accepts normalized timeline events for each equipped
mechanic. Current player pages do not construct this timeline or invoke the
runtime. `movement_snapshot.values.state` is `immobile`, `moving`, or
`sprinting`; the mechanic's moving and sprinting scenario controls select that
state, with sprinting taking precedence. Enabled Skill activation profiles emit
`skill_deployed` events on the same cadence as Skill calculations and identify
the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends.
@@ -413,8 +546,8 @@ Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require
Tier I and may omit Tier II until contributors document its game values.
Builder calculations use Tier I while an equipped exotic Talent has no Tier
II mechanic variant.
Runtime calculations use Tier I while an equipped exotic Talent has no Tier II
mechanic variant.
- OS Protocol Talents store a base value. An outcome may use an expression that
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
values can scale to twice the base at upgrade five.
@@ -431,41 +564,51 @@ configuration state, and one of three simulation-readiness states:
- `captured_only`: ISAC preserves the authored mechanic, but the runtime cannot
calculate it.
The code-defined `MECHANIC_EVENT_REGISTRY` is the event authority. Its 19
The code-defined `MECHANIC_EVENT_REGISTRY` is the event authority. Its 28
normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
`skill_hit`, `agent_hit`, `health_damage_taken`, `damage_dealt`,
`enemy_killed`, `assist`, `healing_applied`, `extra_health_granted`,
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`,
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event
details such as target, distance, critical state, source, movement, and nearby
counts travel as typed event values. The event registry publishes authoritative
field metadata where branches need a bounded contract. For example,
`weapon_hit.values.headshot` is a boolean that distinguishes headshots from
body shots.
`signature_ability_deployed`, `skill_hit`, `skill_multishot_triggered`,
`agent_hit`, `health_damage_taken`, `damage_dealt`, `enemy_killed`, `assist`,
`ally_downed`, `agent_downed`, `ally_rescued`, `grenade_thrown`,
`medical_kit_used`, `healing_applied`, `extra_health_granted`,
`extra_health_changed`, `movement_snapshot`, `proximity_snapshot`,
`firing_started`, `firing_stopped`, `reload_started`, `reload_completed`,
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
state, source, movement, and nearby counts travel as typed event values. The
event registry publishes authoritative field metadata where branches need a
bounded contract. For those events, the mechanic editor derives its event-field
controls from that metadata. For example, `weapon_hit.values.headshot` is a
boolean that distinguishes headshots from body shots.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
changes, and when it is depleted to zero. Every event requires the boolean
`values.hasExtraHealth`. An event may include the non-negative
`values.currentExtraHealth`; when present, the amount must agree with the
boolean status. `extra_health_granted` represents a grant action and remains a
separate event from changes to the Agent's current Extra Health state.
The optional Action Family catalog provides authoring recipes for common
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
clauses into the source-owned program. Those branches become editable
authoritative data; the recipe key remains provenance, not a runtime
dependency. Adding another recipe appends branches. Replacing a program with a
recipe requires confirmation because replacement discards the current branch
graph.
authoritative data; the recipe key remains provenance, not a runtime dependency.
Adding another recipe appends branches. Replacing a program with a recipe
requires confirmation because replacement discards the current branch graph.
A Talent may save an empty `captured_only` program while an author records its
name, category, image, and source description. The registry marks that Talent
`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready`
or `requires_assumptions` restores strict branch, numbered-outcome, and
`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready` or
`requires_assumptions` restores strict branch, numbered-outcome, and
description-placeholder validation.
The program graph uses three child tables:
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
counts, stacks, durations, scalar values, marks, and resources. Each pool
owns its bounds, expiry policy, refresh policy, and best-case value.
- `conditional_effect_clauses` listens for one of the 19 events. A clause owns
a typed predicate, reducer, optional cooldown, optional weight expression,
state mutations, and optional recipe provenance. Reducers convert event
streams into counts, distinct or consecutive matches, sliding windows,
counts, stacks, durations, scalar values, marks, and resources. Each pool owns
its bounds, expiry policy, refresh policy, and best-case value.
- `conditional_effect_clauses` listens for one event from the registry. A clause
owns a typed predicate, reducer, optional cooldown, optional weight
expression, state mutations, and optional recipe provenance. Reducers convert
event streams into counts, distinct or consecutive matches, sliding windows,
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
@@ -475,17 +618,17 @@ The program graph uses three child tables:
value basis, optional resource or Attribute, combat timing, optional state
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
`while_active`. One typed value or expression exists for every variant
required by the owning source. The common value models are static values,
live Attribute values, percentage-normalized Attribute values, event-field values, reducer output,
source-defined scenario values, and OS Protocol upgrade scaling. Restoration
outcomes may deliver their total immediately or continuously over an explicit
duration.
required by the owning source. The common value models are static values, live
Attribute values, percentage-normalized Attribute values, event-field values,
reducer output, source-defined scenario values, and OS Protocol upgrade
scaling. Restoration outcomes may deliver their total immediately or
continuously over an explicit duration.
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events
in timestamp order. Predicates decide whether an event enters a clause. Reducers
convert event streams into activations or derived values. The runtime applies
state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
and records telemetry for each activation.
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
events in timestamp order. Predicates decide whether an event enters a clause.
Reducers convert event streams into activations or derived values. The runtime
applies state mutations, enforces pool bounds and expiry, emits outcomes in
phase order, and records telemetry for each activation.
Each write to an expiring pool refreshes its timer. When a pool expires,
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
@@ -497,9 +640,9 @@ intermediate ticks.
Live Attribute references use a stable Attribute ID and read the authoritative
loadout total that the builder captures for the evaluation pass. Percentage
Attributes retain their displayed units, so an expression that applies a
coefficient to a percentage divides the Attribute value by 100. Evaluation
fails when the loadout does not supply a referenced Attribute; the runtime does
not substitute zero.
coefficient to a percentage divides the Attribute value by 100. Evaluation fails
when the loadout does not supply a referenced Attribute; the runtime does not
substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current
Health by resolved maximum Health and returns a value from 0 through 100. The
@@ -523,17 +666,18 @@ uses `requires_assumptions`, identifies the missing input, and stays out of the
calculation until an operator or builder scenario supplies it.
The runtime derives a read-only pseudo-language listing from the typed program.
ISAC and the builder use that listing to explain events, predicates, reducers,
state changes, phases, and formulas. No route accepts the generated text as
input, and no server process executes it. Operators edit structured fields; the
formatter regenerates the listing after each change.
ISAC uses that listing to explain events, predicates, reducers, state changes,
phases, and formulas. Planned player calculations can reuse it when those pages
consume mechanic outcomes. No route accepts the generated text as input, and no
server process executes it. Operators edit structured fields; the formatter
regenerates the listing after each change.
## AI-assisted mechanic drafting
Manual mechanic authoring remains the complete authoring path. The AI assistant
produces an optional draft for the existing structured editor. It cannot save,
publish, approve, or submit game data, and it cannot alter application code.
An author may ignore, revise, or replace any generated field through the manual
publish, approve, or submit game data, and it cannot alter application code. An
author may ignore, revise, or replace any generated field through the manual
controls.
The shared mechanic editor exposes the mechanic assistant for standard and
@@ -558,12 +702,12 @@ the parser, so CI blocks a prompt that advertises unsupported fields. A mechanic
grammar change must update the manifest and example, pass the round-trip test,
and increment the prompt contract version before release.
The OpenAI integration uses the Responses API with Structured Outputs, no
tools, and `store: false`. The application stores the bounded clarification
history needed for the current drafting session. Requests use a fixed
server-configured model, a privacy-preserving user safety identifier, input and
output limits, timeouts, duplicate-request protection, and per-agent rate
limits. The API key remains on the server.
The OpenAI integration uses the Responses API with Structured Outputs, no tools,
and `store: false`. The application stores the bounded clarification history
needed for the current drafting session. Requests use a fixed server-configured
model, a privacy-preserving user safety identifier, input and output limits,
timeouts, duplicate-request protection, and per-agent rate limits. The API key
remains on the server.
The model returns one structured result:
@@ -576,15 +720,15 @@ The model returns one structured result:
reference, and test changes a developer would need to add support.
For a mechanic capability gap, the client builds a developer prompt from the
exact analyzed wording, clarification transcript, source profile, and
structured gap response. The author can copy that prompt without another model
request. The author can also submit the gap to active full administrators.
The submission endpoint validates bounded fields, confirms that the request ID
belongs to the author's audited `capability_gap` generation, suppresses repeat
submissions, sends mandatory ISAC notifications, and appends a
`mechanic_gap.submitted` event to the `ai_assistance` audit category. The
notification links to the filtered audit evidence. Submission does not mutate
the mechanic draft or parent game-data record.
exact analyzed wording, clarification transcript, source profile, and structured
gap response. The author can copy that prompt without another model request. The
author can also submit the gap to active full administrators. The submission
endpoint validates bounded fields, confirms that the request ID belongs to the
author's audited `capability_gap` generation, suppresses repeat submissions,
sends mandatory ISAC notifications, and appends a `mechanic_gap.submitted` event
to the `ai_assistance` audit category. The notification links to the filtered
audit evidence. Submission does not mutate the mechanic draft or parent
game-data record.
The server validates `draft_ready` output against the response schema and the
same deterministic mechanic parser used by manual authoring. Unknown events,
@@ -605,11 +749,11 @@ allowed by their data role.
Successful generation operations append sanitized events under the
`ai_assistance` audit category. Events identify the operator, source type,
prompt-context version, model, result type, token usage, request ID, and
validation result. Audit snapshots exclude the API key,
environment configuration, hidden model reasoning, authentication data, and
unbounded prompt content. Applying a draft changes client state and creates no
server event. The owning module's game-data audit event remains the record of
any saved mechanic change.
validation result. Audit snapshots exclude the API key, environment
configuration, hidden model reasoning, authentication data, and unbounded prompt
content. Applying a draft changes client state and creates no server event. The
owning module's game-data audit event remains the record of any saved mechanic
change.
### AI-assisted calculation-revision drafting
@@ -617,23 +761,24 @@ The calculation assistant converts an operator's plain-language combat-model
request into an optional draft revision. It receives the current active or
edited revision, the calculation-stage vocabulary, weapon and Skill profile
contracts, and the active ISAC Attribute catalog. Its versioned prompt,
structured response schema, validation, and application adapter remain
separate from mechanic drafting.
structured response schema, validation, and application adapter remain separate
from mechanic drafting.
The assistant returns clarification questions, a validated revision draft, or
a calculation capability gap. A capability gap identifies the missing stage,
The assistant returns clarification questions, a validated revision draft, or a
calculation capability gap. A capability gap identifies the missing stage,
operator, curve, binding, runtime behavior, or validation rule and lists the
application and test changes needed to support it. The server rejects unknown
stage keys, locked-position changes, duplicate bindings, invalid Attribute
references, unsupported operators, and malformed curve or cap values through
the deterministic calculation-revision parser.
references, unsupported operators, and malformed curve or cap values through the
deterministic calculation-revision parser.
The `ai.calculation.generate` permission authorizes calculation drafting and
clarification requests. It does not grant revision mutation or publication.
Applying a result changes the open revision form in the client. The operator
must review and save that draft through the existing revision action. The
separate `calculation.publish` permission and activation confirmation remain
mandatory before the saved draft affects loadout results.
mandatory before the saved draft becomes the active authored model. Current
player pages do not consume that model.
Successful calculation-generation requests append sanitized events under
`ai_assistance`. These events use calculation-specific actions, identify the
@@ -642,35 +787,36 @@ token-usage, result, and secret-exclusion rules as mechanic-assistance events.
Applying a draft creates no server event. Existing `calculation_configuration`
events remain the evidence for revision saves and activation.
Source authors define the builder controls required by their mechanic as typed
toggle, slider, or numeric scenario inputs. Each input records its label,
guidance, bounds when applicable, default value, and best-case value. These
controls appear only in advanced scenario configuration. Maximum mode supplies
best-case values. Custom mode uses the values saved with the loadout. A
mechanic with an undefined required assumption remains excluded and reports
`REQUIRES_ASSUMPTIONS`.
Source authors define planned player scenario controls as typed toggle, slider,
or numeric inputs. Each input records its label, guidance, bounds when
applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead.
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 4 structure. A backed-up prototype migration removes drafts,
saved builds, and calibration snapshots that use incompatible payloads.
the exact version 4 structure. The schema retains its simulation fields for
compatibility, but the current player UI ignores them. A backed-up prototype
migration removes drafts, saved builds, and calibration snapshots that use
incompatible payloads.
## Calculation modes
## Planned player calculation modes
The builder offers `maximum` and `custom` modes.
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
- Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against
their configured target caps. The interface hides granular assumptions in
this mode.
their configured target caps. The interface hides granular assumptions in this
mode.
- Custom mode uses the agent's encounter inputs, effect states, activation
rates, target counts, and other supported assumptions. A missing required
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
Saved and shared loadouts preserve the selected mode. Calculation results label
the mode so readers can distinguish a best-case ceiling from a scenario-based
estimate.
Saved and shared loadouts preserve the selected mode as compatibility data. The
current Builder and shared Build UI does not expose a mode selector, read
encounter assumptions, or execute either mode. Explicit `COMING SOON` panels
reserve the player-facing space for these calculations.
## Specialization ability attacks
@@ -678,8 +824,8 @@ estimate.
Shredding damage, direct or periodic delivery, hit or tick counts, cast time,
target cap, and charge behavior. `skill_attribute_calculation_profiles` binds
Attributes to the versioned Skill damage and cadence stages. Cooldown Recovery
changes charge and activation readiness. Duration changes the number of
periodic ticks.
changes charge and activation readiness. Duration changes the number of periodic
ticks.
Periodic delivery waits one configured tick interval before its first tick and
excludes ticks at or after the encounter boundary. The Skill engine reevaluates
@@ -696,6 +842,12 @@ holds the first position, and the firing-cycle stage holds the last.
`attribute_calculation_profiles` maps an Attribute to a weapon stage with its
weight, optional caps, curve, and weapon-category scope.
ISAC keeps combat revision editing, publication, calibration, assisted
suggestions, and the calculation AI assistant available so operators can
continue authoring the model. Builder and shared Build pages do not load the
active revision or consume revision, calibration, assistant, mechanic-runtime,
or combat-engine output at the current player-facing boundary.
The shared combat engine consumes weapon facts, accumulated Attributes,
calculation profiles, and mechanic outcomes. It produces four hit values, burst
DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode
@@ -704,11 +856,11 @@ configured burst size, pauses between bursts, and encounter duration. It
resolves the active Attribute state at each landed shot before calculating that
shot's expected damage.
The builder scales a weapon definition's level-1 damage range to the equipped
item level, then the agent selects one damage value inside that valid range.
Combat receives `equipped_weapon.damage` as its base damage and consumes that
number once. The combat pipeline has no `level_scaled_damage` value and must not
apply item-level scaling a second time.
The deterministic equipment path scales a weapon definition's level-1 damage
range to the equipped item level, then the agent selects one damage value inside
that valid range. A combat invocation receives `equipped_weapon.damage` as its
base damage and consumes that number once. The combat pipeline has no
`level_scaled_damage` value and must not apply item-level scaling a second time.
The generated calculation listing names each typed pipeline stage and its bound
Attributes. The listing explains the active revision with statements such as
@@ -723,16 +875,15 @@ state. Custom mode can set an effect to Peak, Encounter, or Off.
The Skill engine uses a dedicated intrinsic ability-damage model. It supports
direct and periodic delivery, critical and multi-shot expectation, target caps,
per-ability activation rates, and charge recovery.
Skills do not headshot. Dynamic Talent and tree-effect windows apply to each
Skill hit at its delivery time. Encounter-triggered Skill modifiers use the
primary weapon timeline until the builder supports a combined weapon-rotation
model.
per-ability activation rates, and charge recovery. Skills do not headshot.
Dynamic Talent and tree-effect windows apply to each Skill hit at its delivery
time. Encounter-triggered Skill modifiers use the primary weapon timeline until
the builder supports a combined weapon-rotation model.
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
calculation lacks required inputs or an Attribute binding. Callers must render
that state instead of a partial result. Saved and shared builds load the current
active revision for each request.
that state instead of a partial result. Current player routes do not call the
engine; their planned calculation surfaces display `COMING SOON`.
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
hit values, timing values, optional encounter DPS, and the model prediction.
@@ -740,12 +891,12 @@ Ready cases require the four hit values plus magazine and reload times. The
assisted suggestion action compares ready observations with their predictions
and creates a draft revision for operator review.
Rule activation requires `calculation.publish` and a confirmation.
ISAC records draft creation, draft edits, publication, calibration changes, and
assisted suggestions under the `calculation_configuration` audit category.
ISAC data export and environment synchronization include calculation rules,
typed mechanic programs, ability attacks, calibration cases, and their
relationships in dependency order.
Rule activation requires `calculation.publish` and a confirmation. ISAC records
draft creation, draft edits, publication, calibration changes, and assisted
suggestions under the `calculation_configuration` audit category. ISAC data
export and environment synchronization include calculation rules, typed mechanic
programs, ability attacks, calibration cases, and their relationships in
dependency order.
## OS Protocol upgrade expressions and health timing
@@ -782,9 +933,9 @@ rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory. The
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
cache headers and disables MIME sniffing. ISAC save actions process files
before mutation, apply upload or removal after record validation, and include
safe icon metadata, rather than image bytes, in audit snapshots.
cache headers and disables MIME sniffing. ISAC save actions process files before
mutation, apply upload or removal after record validation, and include safe icon
metadata, rather than image bytes, in audit snapshots.
Icon metadata is optional. Canonical records and contributor proposals may be
created without an upload, which lets contributors submit values and artwork in
@@ -809,23 +960,20 @@ keeps the library metadata and one copy of each file.
The notification router sends Data Change review items to agents who hold
`data.approve` and Avatar Oversight review items to agents who hold
`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or
daily delivery for each authorized queue. Submission creates Immediate alerts;
`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or daily
delivery for each authorized queue. Submission creates Immediate alerts;
`oversight_notification_queue` stores consolidated alerts.
Production must run the idempotent dispatcher once per minute from one scheduler
instance:
The production Compose scheduler calls the internal scheduler endpoint once per
minute. That endpoint runs the idempotent dispatcher in the application process,
which owns the embedded-database connection. Do not run a second host cron job.
```text
* * * * * cd /path/to/shd.build && npm run notifications:oversight:production
```
The dispatcher groups due rows by reviewer, queue, cadence, and delivery
window. Daily rows use the next 10:00 AM wall-clock boundary in
`America/New_York`, including daylight-saving transitions. Dispatched rows
retain `delivered_at` evidence, and later queries exclude them. Existing
ISAC notification delivery preferences determine optional browser and email
fan-out after the dispatcher creates the consolidated inbox message.
The dispatcher groups due rows by reviewer, queue, cadence, and delivery window.
Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`,
including daylight-saving transitions. Dispatched rows retain `delivered_at`
evidence, and later queries exclude them. Existing ISAC notification delivery
preferences determine optional browser and email fan-out after the dispatcher
creates the consolidated inbox message.
## Automated ISAC system messages
@@ -836,19 +984,14 @@ managed through ISAC → System Configuration → Automated Messages by agents w
enabled state, delay in minutes, message content, primary destination, and
structured action callouts.
Password, magic-link, and Discord registration emit `account.created`.
The delivery table has a unique message/user/event/event-ID constraint, so the
same account creation cannot deliver the Welcome Agent message more than once.
The default welcome introduces News, Database, Builds, Guides, Builder, ISAC
Password, magic-link, and Discord registration emit `account.created`. The
delivery table has a unique message/user/event/event-ID constraint, so the same
account creation cannot deliver the Welcome Agent message more than once. The
default welcome introduces News, Database, Builds, Guides, Builder, ISAC
Contributor Mode, and the SHD Discord community.
The registration request delivers Immediate definitions.
`automated_system_message_deliveries` stores definitions with a delay. Production
must run the delayed dispatcher once per minute:
```text
* * * * * cd /path/to/shd.build && npm run notifications:system:production
```
Only one dispatcher instance should run for the embedded database. Delivered
rows retain both their delivery timestamp and generated notification ID.
`automated_system_message_deliveries` stores definitions with a delay. The same
Compose scheduler tick runs the delayed dispatcher. Only one scheduler instance
should call the embedded-database application. Delivered rows retain both their
delivery timestamp and generated notification ID.
+4602 -3702
View File
File diff suppressed because it is too large Load Diff
+82 -80
View File
@@ -1,82 +1,84 @@
{
"name": "shd-build",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
"build:app": "vite build",
"build:staging": "dotenvx run -f .env.staging -- vite build",
"build": "dotenvx run -f .env.production -- vite build",
"brand:render": "node scripts/render-brand-assets.mjs",
"start": "node build",
"preview": "node build",
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
"db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs",
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
"db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs",
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
"isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs",
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs",
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs",
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs",
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
"env:decrypt": "node scripts/env-files.mjs decrypt",
"env:encrypt:development": "dotenvx encrypt -f .env.development",
"env:decrypt:development": "dotenvx decrypt -f .env.development",
"env:encrypt:staging": "dotenvx encrypt -f .env.staging",
"env:decrypt:staging": "dotenvx decrypt -f .env.staging",
"env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs",
"env:encrypt:production": "dotenvx encrypt -f .env.production",
"env:decrypt:production": "dotenvx decrypt -f .env.production",
"env:stage": "node scripts/env-files.mjs stage",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@dotenvx/dotenvx": "^2.17.4",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"@types/nodemailer": "^8.0.1",
"opentype.js": "^2.0.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"dependencies": {
"@fontsource/barlow": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tursodatabase/database": "^0.7.1",
"argon2": "^0.45.1",
"nodemailer": "^9.0.3",
"openai": "^6.49.0",
"sharp": "^0.35.3"
}
"name": "shd-build",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "infisical run --env=dev --path=/app --path=/shared/scheduler -- vite dev --host 0.0.0.0",
"build:app": "vite build",
"build:staging": "vite build",
"build": "vite build",
"brand:render": "node scripts/render-brand-assets.mjs",
"start": "node build",
"preview": "node build",
"db:migrate": "infisical run --env=dev --path=/app --path=/shared/scheduler -- 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": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
"db:restore": "infisical run --env=dev --path=/app -- node scripts/db-restore.mjs",
"db:restore:production": "infisical run --env=prod --path=/app -- node scripts/db-restore.mjs",
"article:export": "infisical run --env=dev --path=/app -- node scripts/article-transfer.mjs export",
"isac:export": "infisical run --env=dev --path=/app -- node scripts/isac-data-export.mjs",
"isac:import": "infisical run --env=dev --path=/app -- node scripts/isac-data-import.mjs",
"isac:attributes:import": "infisical run --env=dev --path=/app -- node scripts/import-resurgence-attributes.mjs",
"isac:export:staging": "infisical run --env=staging --path=/app -- node scripts/isac-data-export.mjs",
"images:export": "infisical run --env=dev --path=/app -- node scripts/managed-images-export.mjs",
"images:import": "infisical run --env=dev --path=/app -- node scripts/managed-images-import.mjs",
"images:import:staging": "infisical run --env=staging --path=/app -- node scripts/managed-images-import.mjs",
"images:import:production": "infisical run --env=prod --path=/app -- node scripts/managed-images-import.mjs",
"stage:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/stage-bootstrap-export.mjs",
"production:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/environment-bootstrap-export.mjs production",
"notifications:digest": "infisical run --env=dev --path=/app -- node scripts/notification-digest.mjs",
"notifications:digest:production": "infisical run --env=prod --path=/app -- node scripts/notification-digest.mjs",
"notifications:oversight": "infisical run --env=dev --path=/app -- node scripts/oversight-notification-dispatch.mjs",
"notifications:oversight:production": "infisical run --env=prod --path=/app -- node scripts/oversight-notification-dispatch.mjs",
"notifications:system": "infisical run --env=dev --path=/app -- node scripts/system-notification-dispatch.mjs",
"notifications:system:production": "infisical run --env=prod --path=/app -- node scripts/system-notification-dispatch.mjs",
"notifications:seed-test": "infisical run --env=dev --path=/app -- node scripts/seed-test-notifications.mjs",
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
"prepare": "node scripts/prepare.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"devDependencies": {
"@infisical/cli": "0.43.116",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"@types/nodemailer": "^8.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"opentype.js": "^2.0.0",
"prettier": "3.9.6",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"dependencies": {
"@fontsource/barlow": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tiptap/core": "3.29.2",
"@tiptap/extension-link": "3.29.2",
"@tiptap/extensions": "3.29.2",
"@tiptap/pm": "3.29.2",
"@tiptap/starter-kit": "3.29.2",
"@tursodatabase/database": "^0.7.1",
"argon2": "^0.45.1",
"nodemailer": "^9.0.3",
"openai": "^6.49.0",
"sharp": "^0.35.3"
}
}
+425
View File
@@ -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();
+302 -38
View File
@@ -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 Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
{"name":"Weapon Multi-Shot Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
{"name":"Skill Critical Chance","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"}
{
"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 Damage",
"unit": "%",
"type": "Offensive",
"category": "Weapon",
"calculationType": "Additive",
"gearRange": [2, 5],
"weaponRange": [4, 10]
},
{
"name": "Weapon Multi-Shot Chance",
"unit": "%",
"type": "Offensive",
"category": "Weapon",
"calculationType": "Additive",
"gearRange": [2, 5],
"weaponRange": [4, 10]
},
{
"name": "Skill Critical Chance",
"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
View File
@@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
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 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 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 });
const database = await connect(databasePath);
const tableRows = await (
await database.prepare(`
await database.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
@@ -27,23 +33,25 @@ const tableRows = await (
const tables = [];
let totalRows = 0;
for (const tableRow of tableRows) {
const name = String(tableRow.name);
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
const name = String(tableRow.name);
const rows = await (
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
).all();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
}
const backup = {
format: 'shd-build-database-backup',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables
format: 'shd-build-database-backup',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables,
};
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx'
encoding: 'utf8',
flag: 'wx',
});
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
+2164 -1187
View File
File diff suppressed because it is too large Load Diff
+41 -30
View File
@@ -5,53 +5,64 @@ import process from 'node:process';
const backupArgument = process.argv[2];
if (!backupArgument) {
console.error('Usage: npm run db:restore -- <backup-file>');
process.exit(2);
console.error('Usage: npm run db:restore -- <backup-file>');
process.exit(2);
}
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'));
if (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.');
if (
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);
// Restores are deliberately limited to empty databases. This prevents an
// accidental merge from overwriting contributor-entered records.
for (const table of backup.tables) {
const existing = await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
).get();
if (Number(existing?.count) > 0) {
throw new Error(
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
);
}
const existing = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
)
).get();
if (Number(existing?.count) > 0) {
throw new Error(
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`,
);
}
}
let restoredRows = 0;
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
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) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
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) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
await database.exec('ROLLBACK');
throw error;
}
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
+87 -70
View File
@@ -2,30 +2,30 @@ import { argon2id, hash } from 'argon2';
import { randomBytes, randomUUID } from 'node:crypto';
export const defaultUsers = [
{
username: 'Aewyn',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
},
{
username: 'Quartermaster',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
}
{
username: 'Aewyn',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD',
},
{
username: 'Quartermaster',
role: 'user',
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD',
},
];
function temporaryPassword() {
// 120 bits of entropy encoded without punctuation that is awkward to paste.
return randomBytes(15).toString('base64url');
// 120 bits of entropy encoded without punctuation that is awkward to paste.
return randomBytes(15).toString('base64url');
}
async function secureHash(password) {
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
});
}
/**
@@ -33,61 +33,78 @@ async function secureHash(password) {
* users keep their IDs and related records but receive new password hashes.
*/
export async function initializeDefaultUsers(database, options = {}) {
const resetPasswords = options.resetPasswords === true;
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
const updateCredential = await database.prepare(
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?'
);
const credentials = [];
const resetPasswords = options.resetPasswords === true;
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE',
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const updateCredential = await database.prepare(
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?',
);
const credentials = [];
for (const definition of defaultUsers) {
const existing = await findUser.get(definition.username, definition.username);
if (existing && !resetPasswords) {
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
continue;
}
for (const definition of defaultUsers) {
const existing = await findUser.get(
definition.username,
definition.username,
);
if (existing && !resetPasswords) {
console.log(
`${definition.username}: already exists (${existing.role}); left unchanged`,
);
continue;
}
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
const password = configuredPassword || temporaryPassword();
if (password.length < 12 || password.length > 128) {
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
}
const passwordHash = await secureHash(password);
const configuredPassword =
process.env[definition.passwordEnvironmentKey]?.trim();
const password = configuredPassword || temporaryPassword();
if (password.length < 12 || password.length > 128) {
throw new Error(
`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`,
);
}
const passwordHash = await secureHash(password);
if (existing) {
await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id);
console.log(`${definition.username}: credentials reset; role and related data preserved`);
} else {
await insertUser.run(
randomUUID(),
definition.username,
definition.username,
passwordHash,
definition.role,
new Date().toISOString()
);
console.log(`${definition.username}: default account created`);
}
credentials.push({
username: definition.username,
role: existing ? String(existing.role) : definition.role,
password,
generated: !configuredPassword
});
}
if (existing) {
await updateCredential.run(
definition.username,
passwordHash,
String(existing.role),
existing.id,
);
console.log(
`${definition.username}: credentials reset; role and related data preserved`,
);
} else {
await insertUser.run(
randomUUID(),
definition.username,
definition.username,
passwordHash,
definition.role,
new Date().toISOString(),
);
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) {
console.log('\nDefault user credentials (shown once):');
for (const credential of credentials) {
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
}
console.log('Change generated passwords after the first sign-in.\n');
}
if (credentials.length) {
console.log('\nDefault user credentials (shown once):');
for (const credential of credentials) {
console.log(
`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`,
);
}
console.log('Change generated passwords after the first sign-in.\n');
}
return credentials;
return credentials;
}
-32
View File
@@ -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.');
}
+67 -36
View File
@@ -5,50 +5,81 @@ import process from 'node:process';
const target = process.argv[2];
if (!['staging', 'production'].includes(target)) {
console.error('Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]');
process.exit(2);
console.error(
'Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]',
);
process.exit(2);
}
const outputArgument = process.argv[3];
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`);
const timestamp = new Date()
.toISOString()
.replaceAll(':', '-')
.replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(
outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`,
);
const temporaryBackupPath = `${outputPath}.database.json`;
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
});
const databaseBackup = spawnSync(
process.execPath,
['scripts/db-backup.mjs', temporaryBackupPath],
{
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
},
);
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) {
try {
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
return await Promise.all(names.map(async (name) => ({
name,
contentBase64: (await readFile(join(directory, name))).toString('base64')
})));
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
try {
const names = (await readdir(directory))
.filter((name) => pattern.test(name))
.sort();
return await Promise.all(
names.map(async (name) => ({
name,
contentBase64: (await readFile(join(directory, name))).toString(
'base64',
),
})),
);
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
try {
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 gameIcons = await collectFiles(resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), /^[a-z_]+-[0-9a-f-]{36}\.webp$/i);
const bundle = {
format: 'shd-build-environment-bootstrap',
version: 1,
target,
createdAt: new Date().toISOString(),
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}`);
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 gameIcons = await collectFiles(
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
);
const bundle = {
format: 'shd-build-environment-bootstrap',
version: 1,
target,
createdAt: new Date().toISOString(),
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 {
await rm(temporaryBackupPath, { force: true });
await rm(temporaryBackupPath, { force: true });
}
+131 -65
View File
@@ -4,112 +4,178 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
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 target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
const allowed = target === 'staging'
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
: target === 'production'
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
: false;
if (!bundleArgument || !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 allowed =
target === 'staging'
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
: target === 'production'
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
: false;
if (
!bundleArgument ||
!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 bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (bundle?.format !== 'shd-build-environment-bootstrap' || bundle?.version !== 1 || bundle?.target !== 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.');
if (
bundle?.format !== 'shd-build-environment-bootstrap' ||
bundle?.version !== 1 ||
bundle?.target !== 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) {
if (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.');
}
if (
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) {
if (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.');
}
if (
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'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
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);
for (const tableName of ['users', 'builds', '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.`);
}
for (const tableName of [
'users',
'builds',
'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
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY rowid
`)).all();
`)
).all();
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) {
if (!sourceNames.has(String(row.name))) {
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
}
if (!sourceNames.has(String(row.name))) {
throw new Error(
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
);
}
}
const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
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;
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`,
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
await database.exec('ROLLBACK');
throw error;
} 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 gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const avatarDirectory = resolve(
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(gameIconDirectory, { recursive: true });
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) {
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}`);
+164 -99
View File
@@ -4,121 +4,186 @@ import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
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'));
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();
for (const record of records) {
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'})`);
identities.add(identity);
if (!['Offensive', 'Defensive'].includes(record.type)
|| !['Weapon', 'Skill', 'Character'].includes(record.category)
|| !['Additive', 'Multiplicative'].includes(record.calculationType)
|| !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')) {
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 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'})`,
);
identities.add(identity);
if (
!['Offensive', 'Defensive'].includes(record.type) ||
!['Weapon', 'Skill', 'Character'].includes(record.category) ||
!['Additive', 'Multiplicative'].includes(record.calculationType) ||
!['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')
) {
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 {
const { getDatabase } = await vite.ssrLoadModule('/src/lib/server/database.ts');
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
const { getDatabase } = await vite.ssrLoadModule(
'/src/lib/server/database.ts',
);
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_roles link ON link.user_id=users.id
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
LIMIT 1`)).get('Aewyn', 'Aewyn');
if (!actor?.id) throw new Error('No administrator account is available to own the Attribute import audit events.');
LIMIT 1`)
).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 updated = 0;
let unchanged = 0;
await database.exec('BEGIN');
try {
for (const record of records) {
const name = String(record.name).normalize('NFKC').trim();
const unit = String(record.unit ?? '');
const existing = await (await database.prepare(
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?'
)).get(name, unit);
const id = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const next = {
...(existing ?? {}),
id,
name,
description: existing?.description ?? null,
type: record.type,
category: record.category,
calculation_type: record.calculationType,
unit,
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
skill_chip_min: existing?.skill_chip_min ?? null,
skill_chip_max: existing?.skill_chip_max ?? null,
cap_value: existing?.cap_value ?? null,
active: 1,
created_at: String(existing?.created_at ?? now),
updated_at: now
};
const comparableExisting = existing ? { ...existing, updated_at: now } : null;
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
unchanged++;
continue;
}
const before = existing ? await auditRecord('stat_attributes', id) : null;
if (existing) {
await (await database.prepare(`UPDATE stat_attributes SET
let created = 0;
let updated = 0;
let unchanged = 0;
await database.exec('BEGIN');
try {
for (const record of records) {
const name = String(record.name).normalize('NFKC').trim();
const unit = String(record.unit ?? '');
const existing = await (
await database.prepare(
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?',
)
).get(name, unit);
const id = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const next = {
...(existing ?? {}),
id,
name,
description: existing?.description ?? null,
type: record.type,
category: record.category,
calculation_type: record.calculationType,
unit,
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
skill_chip_min: existing?.skill_chip_min ?? null,
skill_chip_max: existing?.skill_chip_max ?? null,
cap_value: existing?.cap_value ?? null,
active: 1,
created_at: String(existing?.created_at ?? now),
updated_at: now,
};
const comparableExisting = existing
? { ...existing, updated_at: now }
: null;
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
unchanged++;
continue;
}
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=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
cap_value=?,active=1,updated_at=? WHERE id=?`)).run(
next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
next.gear_min, next.gear_max, next.weapon_min, next.weapon_max, next.skill_chip_min,
next.skill_chip_max, next.cap_value, now, id
);
updated++;
} else {
await (await database.prepare(`INSERT INTO stat_attributes(
cap_value=?,active=1,updated_at=? WHERE id=?`)
).run(
next.name,
next.description,
next.type,
next.category,
next.calculation_type,
next.unit,
next.gear_min,
next.gear_max,
next.weapon_min,
next.weapon_max,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
id,
);
updated++;
} else {
await (
await database.prepare(`INSERT INTO stat_attributes(
id,name,description,type,category,calculation_type,unit,
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
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)).run(
id, next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
next.gear_min, next.gear_max, next.gear_reference_level, next.weapon_min, next.weapon_max,
next.weapon_reference_level, next.skill_chip_min, next.skill_chip_max, next.cap_value, now, now
);
created++;
}
await recordIsacAudit({
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}`);
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
).run(
id,
next.name,
next.description,
next.type,
next.category,
next.calculation_type,
next.unit,
next.gear_min,
next.gear_max,
next.gear_reference_level,
next.weapon_min,
next.weapon_max,
next.weapon_reference_level,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
now,
);
created++;
}
await recordIsacAudit({
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 {
await vite.close();
await vite.close();
}
+63 -29
View File
@@ -4,49 +4,83 @@ import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
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 databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
);
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 tables = [];
let totalRows = 0;
for (const name of ISAC_DATA_TABLES) {
const exists = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?"
)).get(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();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
const exists = await (
await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
)
).get(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();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
}
const icons = [];
const exportedStorageKeys = new Set();
const iconTable = tables.find((table) => table.name === 'game_data_icons');
const managedImageTable = tables.find((table) => table.name === 'managed_images');
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
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)}".`);
}
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 managedImageTable = tables.find(
(table) => table.name === 'managed_images',
);
for (const row of [
...(iconTable?.rows ?? []),
...(managedImageTable?.rows ?? []),
]) {
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)}".`,
);
}
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 = {
format: 'shd-build-isac-data',
version: 2,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables,
icons
format: 'shd-build-isac-data',
version: 2,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables,
icons,
};
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}`);
+89 -43
View File
@@ -5,39 +5,77 @@ import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
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 snapshotArgument = process.argv
.slice(2)
.find((argument) => !argument.startsWith('--'));
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 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)) {
throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.');
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 2 ||
!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);
if (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.');
if (
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.',
);
}
for (const icon of snapshot.icons) {
if (typeof icon.storageKey !== 'string' || !/^[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.');
}
if (
typeof icon.storageKey !== 'string' ||
!/^[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 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`);
const timestamp = new Date()
.toISOString()
.replaceAll(':', '-')
.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 [
['scripts/db-backup.mjs', backupPath],
['scripts/isac-data-export.mjs', isacBackupPath]
['scripts/db-backup.mjs', backupPath],
['scripts/isac-data-export.mjs', isacBackupPath],
]) {
const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), 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 result = spawnSync(process.execPath, [script, output], {
cwd: process.cwd(),
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);
@@ -45,35 +83,43 @@ let importedRows = 0;
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
for (const name of [...ISAC_DATA_TABLES].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
}
for (const table of snapshot.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`
);
await insert.run(...columns.map((column) => row[column]));
importedRows++;
}
}
await database.exec('COMMIT');
for (const name of [...ISAC_DATA_TABLES].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
}
for (const table of snapshot.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`,
);
await insert.run(...columns.map((column) => row[column]));
importedRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
await database.exec('ROLLBACK');
throw error;
} 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 });
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(`ISAC pre-import snapshot: ${isacBackupPath}`);
+74 -73
View File
@@ -1,76 +1,77 @@
export const ISAC_DATA_TABLES = [
'stat_attributes',
'talents',
'talent_tiers',
'conditional_effect_rules',
'conditional_effect_state_pools',
'conditional_effect_clauses',
'conditional_effect_outcomes',
'talent_tier_effects',
'talent_description_templates',
'talent_weapon_scopes',
'weapon_talent_slots',
'brand_sets',
'brand_set_effects',
'gear',
'gear_brand_sets',
'gear_talents',
'gear_tactical_augments',
'gear_tactical_augment_costs',
'weapons',
'core_attribute_tier_ranges',
'weapon_tier_ranges',
'weapon_talent_options',
'weapon_augmentation_effects',
'weapon_category_attribute_bonuses',
'os_protocols',
'os_protocol_primary_effects',
'os_protocol_secondary_profiles',
'os_protocol_secondary_effects',
'os_protocol_talents',
'os_protocol_talent_effects',
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_secondary_options',
'skill_chip_talents',
'skill_chip_talent_effects',
'weapon_mods',
'weapon_mod_compatible_categories',
'weapon_mod_slots',
'weapon_mod_quality_values',
'weapon_mod_secondary_options',
'weapon_mod_secondary_quality_values',
'weapon_mod_exotic_attributes',
'weapon_mod_effects',
'weapon_mod_effect_quality_values',
'gear_mods',
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'specialization_progression_config',
'specialization_rank_cost_profiles',
'upgrade_resources',
'specializations',
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
'specialization_tree_nodes',
'specialization_tree_node_ranks',
'specialization_tree_rank_effects',
'specialization_tree_rank_costs',
'specialization_tree_rank_combat_effects',
'specialization_tree_rank_combat_modifiers',
'calculation_revisions',
'calculation_stages',
'attribute_calculation_profiles',
'skill_attribute_calculation_profiles',
'calculation_calibration_cases',
'game_data_icons',
'managed_images'
'stat_attributes',
'talents',
'talent_tiers',
'conditional_effect_rules',
'conditional_effect_state_pools',
'conditional_effect_clauses',
'conditional_effect_outcomes',
'talent_tier_effects',
'talent_description_templates',
'talent_weapon_scopes',
'weapon_talent_slots',
'brand_sets',
'brand_set_effects',
'gear',
'gear_brand_sets',
'gear_talents',
'gear_tactical_augments',
'gear_tactical_augment_costs',
'weapons',
'core_attribute_tier_ranges',
'weapon_tier_ranges',
'weapon_talent_options',
'weapon_augmentation_effects',
'weapon_category_attribute_bonuses',
'os_protocols',
'os_protocol_primary_effects',
'os_protocol_secondary_profiles',
'os_protocol_secondary_effects',
'os_protocol_talents',
'os_protocol_talent_effects',
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_secondary_options',
'skill_chip_talents',
'skill_chip_talent_effects',
'weapon_mods',
'weapon_mod_compatible_categories',
'weapon_mod_slots',
'weapon_mod_quality_values',
'weapon_mod_secondary_options',
'weapon_mod_secondary_quality_values',
'weapon_mod_exotic_attributes',
'weapon_mod_effects',
'weapon_mod_effect_quality_values',
'gear_mods',
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'specialization_progression_config',
'specialization_rank_cost_profiles',
'upgrade_resources',
'specializations',
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
'specialization_tree_nodes',
'specialization_tree_node_ranks',
'specialization_tree_rank_effects',
'specialization_tree_rank_costs',
'specialization_tree_rank_combat_effects',
'specialization_tree_rank_combat_modifiers',
'calculation_revisions',
'calculation_stages',
'attribute_calculation_profiles',
'skill_attribute_calculation_profiles',
'calculation_calibration_cases',
'game_data_icons',
'managed_images',
];
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
+41 -16
View File
@@ -2,28 +2,53 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
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 databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
);
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 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 = [];
for (const row of rows) {
if (typeof row.storage_key !== 'string'
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
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') });
if (
typeof row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
row.storage_key,
)
) {
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 = {
format: 'shd-build-managed-images',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
images
format: 'shd-build-managed-images',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
images,
};
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(`Image bundle: ${outputPath}`);
+192 -79
View File
@@ -4,47 +4,84 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
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')) {
console.error('Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]');
process.exit(2);
console.error(
'Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]',
);
process.exit(2);
}
const updateExisting = process.argv.includes('--update-existing');
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (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.');
if (
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) {
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|| typeof image.row.storage_key !== '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)
|| typeof image.contentBase64 !== 'string') {
throw new Error('The bundle contains an invalid managed image.');
}
const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type);
if (image.row.source_entity_type && (
typeof image.row.source_entity_id !== 'string'
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|| (isItemRender
? !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.');
if (
typeof image?.row?.id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
typeof image.row.storage_key !== '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,
) ||
typeof image.contentBase64 !== 'string'
) {
throw new Error('The bundle contains an invalid managed image.');
}
const isItemRender = ['weapon_render', 'gear_render'].includes(
image.row.source_entity_type,
);
if (
image.row.source_entity_type &&
(typeof image.row.source_entity_id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
(isItemRender
? !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'], {
cwd: process.cwd(), stdio: 'inherit', env: process.env
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
if (backup.error) throw backup.error;
if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.');
const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], {
cwd: process.cwd(), stdio: 'inherit', env: process.env
});
if (backup.status !== 0)
throw new Error('Safety backup failed; no managed images were imported.');
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.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 storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const databasePath = resolve(
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);
await mkdir(storageDirectory, { recursive: true });
let inserted = 0;
@@ -53,64 +90,140 @@ let unchanged = 0;
const preflight = [];
for (const image of bundle.images) {
const row = image.row;
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
if (existing && !updateExisting) {
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id']
.every((key) => String(existing[key]) === String(row[key]));
if (!same) 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 });
const row = image.row;
const existing = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(row.id);
if (existing && !updateExisting) {
const same = [
'name',
'category',
'tags_json',
'is_icon',
'force_white',
'storage_key',
'byte_size',
'width',
'height',
'source_entity_type',
'source_entity_id',
].every((key) => String(existing[key]) === String(row[key]));
if (!same)
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');
try {
for (const { image, existing } of preflight) {
const row = image.row;
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
mode: 0o600, flag: existing || row.source_entity_type ? 'w' : 'wx'
});
if (existing) {
await (await database.prepare(`UPDATE managed_images SET
for (const { image, existing } of preflight) {
const row = image.row;
await writeFile(
join(storageDirectory, row.storage_key),
Buffer.from(image.contentBase64, 'base64'),
{
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=?,
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)).run(
row.name,row.category,row.tags_json,row.is_icon,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
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)
).run(
row.name,
row.category,
row.tags_json,
row.is_icon,
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,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
row.id,row.name,row.category,row.tags_json,row.is_icon,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.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');
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
row.id,
row.name,
row.category,
row.tags_json,
row.is_icon,
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.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) {
await database.exec('ROLLBACK');
throw error;
await database.exec('ROLLBACK');
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.`,
);
+65 -27
View File
@@ -2,40 +2,78 @@ import { connect } from '@tursodatabase/database';
import nodemailer from 'nodemailer';
import { resolve } from 'node:path';
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM)
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({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true,
});
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
/\/+$/,
'',
);
const recipients = await (
await database.prepare(`SELECT DISTINCT n.user_id,e.email
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 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;
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'
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);
if (!items.length) continue;
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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;
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);
if (!items.length) continue;
const escape = (value) =>
String(value).replace(
/[&<>"']/g,
(character) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[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'}.`);
+85 -52
View File
@@ -3,67 +3,100 @@ import nodemailer from 'nodemailer';
import { randomUUID } from 'node:crypto';
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 due = await (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 due = await (
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();
for (const item of due) {
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
const transport = smtpConfigured ? nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
}) : null;
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
const transport = smtpConfigured
? nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true,
})
: null;
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
/\/+$/,
'',
);
let delivered = 0;
for (const items of groups.values()) {
const first = items[0];
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).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
const first = items[0];
const categoryLabel =
first.oversight_category === 'avatars' ? 'avatar' : 'data change';
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
const details = items
.slice(0, 10)
.map((item) => `• ${item.summary}`)
.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)
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
notificationId, first.user_id, title, body, first.link, now
);
for (const item of items) {
await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id);
}
const preference = await (await database.prepare(
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
)).get(first.user_id);
if (transport && preference?.email_frequency === 'immediate') {
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
if (typeof identity?.email === 'string') {
try {
await transport.sendMail({
from: process.env.SMTP_FROM,
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>`
});
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;
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)
).run(notificationId, first.user_id, title, body, first.link, now);
for (const item of items) {
await (
await database.prepare(
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL',
)
).run(now, item.id);
}
const preference = await (
await database.prepare(
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
)
).get(first.user_id);
if (transport && preference?.email_frequency === 'immediate') {
const identity = await (
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
).get(first.user_id);
if (typeof identity?.email === 'string') {
try {
await transport.sendMail({
from: process.env.SMTP_FROM,
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>`,
});
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'}.`,
);
+11
View File
@@ -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();
}
+47 -28
View File
@@ -6,35 +6,47 @@ import sharp from 'sharp';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputDirectory = path.join(root, 'static', 'brand');
const fontDirectory = path.join(root, '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);
const fontDirectory = path.join(
root,
'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 });
const colors = {
background: '#0a0c0d',
surface: '#111415',
border: '#303536',
grid: '#2a3031',
orange: '#f6a821',
white: '#ecebe5'
background: '#0a0c0d',
surface: '#111415',
border: '#303536',
grid: '#2a3031',
orange: '#f6a821',
white: '#ecebe5',
};
function trackedPath(font, value, x, baseline, size, tracking = 0) {
let cursor = x;
let data = '';
for (const character of value) {
const glyph = font.charToGlyph(character);
data += glyph.getPath(cursor, baseline, size).toPathData(2);
cursor += glyph.advanceWidth * size / font.unitsPerEm + tracking;
}
return { data, width: cursor - x - tracking };
let cursor = x;
let data = '';
for (const character of value) {
const glyph = font.charToGlyph(character);
data += glyph.getPath(cursor, baseline, size).toPathData(2);
cursor += (glyph.advanceWidth * size) / font.unitsPerEm + tracking;
}
return { data, width: cursor - x - tracking };
}
function emblem(x, y, size) {
const scale = size / 100;
return `
const scale = size / 100;
return `
<g transform="translate(${x} ${y}) scale(${scale})">
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
<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) {
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'fill' })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(path.join(outputDirectory, filename));
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'fill' })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(path.join(outputDirectory, filename));
}
const transparentLogo = `
@@ -154,10 +166,17 @@ const inviteSplash = `
await writePng(transparentLogo, 'shd-core-segments.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(inviteSplash, 'shd-build-discord-invite-background-1920x1080.png', 1920, 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'));
await writePng(
inviteSplash,
'shd-build-discord-invite-background-1920x1080.png',
1920,
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}`);
+232
View File
@@ -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.',
);
}
}
+161
View File
@@ -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');
});
});
+251
View File
@@ -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}.`));
}
});
});
}
+14 -12
View File
@@ -1,17 +1,19 @@
const endpoint = process.env.SCHEDULER_URL;
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) {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
} catch (error) {
console.error('Scheduler tick failed', error);
}
await new Promise((resolve) => setTimeout(resolve, 60_000));
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
});
if (!response.ok)
throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
} catch (error) {
console.error('Scheduler tick failed', error);
}
await new Promise((resolve) => setTimeout(resolve, 60_000));
}
+3 -1
View File
@@ -3,7 +3,9 @@ import { resolve } from 'node:path';
import process from 'node:process';
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 resetPasswords = process.argv.includes('--reset-passwords');
+51 -10
View File
@@ -3,22 +3,63 @@ import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
const targetName = process.argv[2] || 'Aewyn';
const database = await connect(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
const database = await connect(
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
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}.`);
const now = Date.now();
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],
['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]
[
'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,
],
[
'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()) {
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)
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}.`,
);
+62 -39
View File
@@ -4,51 +4,74 @@ import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
const outputArgument = process.argv[2];
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`);
const timestamp = new Date()
.toISOString()
.replaceAll(':', '-')
.replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(
outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`,
);
const temporaryBackupPath = `${outputPath}.database.json`;
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
});
const databaseBackup = spawnSync(
process.execPath,
['scripts/db-backup.mjs', temporaryBackupPath],
{
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
},
);
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) {
try {
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
return await Promise.all(names.map(async (name) => ({
name,
contentBase64: (await readFile(join(directory, name))).toString('base64')
})));
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
try {
const names = (await readdir(directory))
.filter((name) => pattern.test(name))
.sort();
return await Promise.all(
names.map(async (name) => ({
name,
contentBase64: (await readFile(join(directory, name))).toString(
'base64',
),
})),
);
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
try {
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 gameIcons = await collectFiles(
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i
);
const bundle = {
format: 'shd-build-stage-bootstrap',
version: 1,
createdAt: new Date().toISOString(),
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(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
console.log(`Bootstrap bundle: ${outputPath}`);
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 gameIcons = await collectFiles(
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
);
const bundle = {
format: 'shd-build-stage-bootstrap',
version: 1,
createdAt: new Date().toISOString(),
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(
`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`,
);
console.log(`Bootstrap bundle: ${outputPath}`);
} finally {
await rm(temporaryBackupPath, { force: true });
await rm(temporaryBackupPath, { force: true });
}
+120 -61
View File
@@ -4,106 +4,165 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import process from 'node:process';
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
if (!bundleArgument || !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 bundleArgument = process.argv
.slice(2)
.find((argument) => !argument.startsWith('--'));
if (
!bundleArgument ||
!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 bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1
|| 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 staging bootstrap bundle.');
if (
bundle?.format !== 'shd-build-stage-bootstrap' ||
bundle?.version !== 1 ||
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 staging bootstrap bundle.',
);
}
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') {
throw new Error('The bootstrap bundle contains an invalid avatar file.');
}
if (
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) {
if (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.');
}
if (
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'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
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);
for (const tableName of ['users', 'builds', '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.`);
}
for (const tableName of [
'users',
'builds',
'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
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY rowid
`)).all();
`)
).all();
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) {
if (!sourceNames.has(String(row.name))) {
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
}
if (!sourceNames.has(String(row.name))) {
throw new Error(
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
);
}
}
const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
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;
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`,
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
await database.exec('ROLLBACK');
throw error;
} 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 gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const avatarDirectory = resolve(
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(gameIconDirectory, { recursive: true });
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) {
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}`);
-46
View File
@@ -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.');
+89 -51
View File
@@ -3,64 +3,102 @@ import nodemailer from 'nodemailer';
import { randomUUID } from 'node:crypto';
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 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
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
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 transport = smtpConfigured ? nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
}) : null;
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[character]);
const transport = smtpConfigured
? nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true,
})
: null;
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
/\/+$/,
'',
);
const escapeHtml = (value) =>
String(value).replace(
/[&<>"']/g,
(character) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character],
);
let delivered = 0;
for (const message of due) {
const notificationId = randomUUID();
await (await database.prepare(`INSERT INTO notifications
const notificationId = randomUUID();
await (
await database.prepare(`INSERT INTO notifications
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
notificationId, message.user_id, message.source, message.title, message.body,
message.link ?? null, message.actions_json ?? null, now
);
await (await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
.run(now, notificationId, message.delivery_id);
const preference = await (await database.prepare(
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
)).get(message.user_id);
if (transport && preference?.email_frequency === 'immediate') {
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
if (typeof identity?.email === 'string') {
try {
await transport.sendMail({
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;
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)
).run(
notificationId,
message.user_id,
message.source,
message.title,
message.body,
message.link ?? null,
message.actions_json ?? null,
now,
);
await (
await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)
).run(now, notificationId, message.delivery_id);
const preference = await (
await database.prepare(
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
)
).get(message.user_id);
if (transport && preference?.email_frequency === 'immediate') {
const identity = await (
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
).get(message.user_id);
if (typeof identity?.email === 'string') {
try {
await transport.sendMail({
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'}.`,
);
+31 -27
View File
@@ -4,37 +4,41 @@ import { join, resolve } from 'node:path';
const iconDirectory = resolve('./static');
const iconFiles = (await readdir(iconDirectory))
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
.sort((left, right) => left.localeCompare(right));
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
.sort((left, right) => left.localeCompare(right));
for (const file of iconFiles) {
const source = join(iconDirectory, file);
const temporary = join(iconDirectory, `.${file}.trimmed`);
const before = await sharp(source).metadata();
const source = join(iconDirectory, file);
const temporary = join(iconDirectory, `.${file}.trimmed`);
const before = await sharp(source).metadata();
try {
const result = await sharp(source)
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
.extend({
top: 2,
right: 2,
bottom: 2,
left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(temporary);
try {
const result = await sharp(source)
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
.extend({
top: 2,
right: 2,
bottom: 2,
left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(temporary);
if (!result.width || !result.height || result.channels !== 4) {
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
}
if (!result.width || !result.height || result.channels !== 4) {
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
}
await rename(temporary, source);
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
} catch (error) {
await unlink(temporary).catch(() => {});
throw error;
}
await rename(temporary, source);
console.log(
`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`,
);
} 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.`,
);
+20
View File
@@ -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);
}
+18203 -2063
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -1,16 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
message: string;
errorId?: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
interface Error {
message: string;
errorId?: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+9 -9
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+137 -9
View File
@@ -1,12 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>%sveltekit.status% · SHD.build system fault</title>
<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}
</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>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>%sveltekit.status% · SHD.build system fault</title>
<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: 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>
+143 -81
View File
@@ -2,89 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
import { stringify } from 'devalue';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access';
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
import {
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 }) => {
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } });
}
const siteSettings = await getSiteAccessSettings();
const sessionUser = await getSessionUser(event.cookies);
const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser));
if (accessBlock) {
const target = new URL('/coming-soon', event.url);
if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section);
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
}
return json(
{
error: accessBlock.kind === 'section'
? 'This section is not available.'
: 'The public site is not available yet.'
},
{ status: 404 }
);
}
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
if (module && DATA_MODULES.includes(module) && action) {
// Publishing calculation rules is never a contributor proposal. The
// action has its own dedicated permission and confirmation contract.
if (module === 'calculations' && action === 'publishRevision') return resolve(event);
const user = sessionUser;
const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) {
if (!access.contributorEnabled) {
if (event.request.headers.get('accept')?.includes('application/json')) {
return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 });
}
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(user, module, action, 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);
if (
event.url.pathname === '/admin' ||
event.url.pathname.startsWith('/admin/')
) {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } });
}
const siteSettings = await getSiteAccessSettings();
const sessionUser = await getSessionUser(event.cookies);
const accessBlock = getSiteAccessBlock(
siteSettings,
event.url.pathname,
Boolean(sessionUser),
);
if (accessBlock) {
const target = new URL('/coming-soon', event.url);
if (accessBlock.kind === 'section')
target.searchParams.set('unavailable', accessBlock.section);
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
return new Response(null, {
status: 303,
headers: { location: `${target.pathname}${target.search}` },
});
}
return json(
{
error:
accessBlock.kind === 'section'
? 'This section is not available.'
: 'The public site is not available yet.',
},
{ status: 404 },
);
}
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/')
? event.url.search.slice(2)
: '';
if (module && DATA_MODULES.includes(module) && action) {
// Publishing calculation rules is never a contributor proposal. The
// action has its own dedicated permission and confirmation contract.
if (module === 'calculations' && action === 'publishRevision')
return resolve(event);
const user = sessionUser;
const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) {
if (!access.contributorEnabled) {
if (
event.request.headers.get('accept')?.includes('application/json')
) {
return json(
{
type: 'failure',
status: 403,
data: { contributorModeRequired: true },
},
{ status: 403 },
);
}
return new Response(
'Contributor mode must be enabled before submitting data changes.',
{ status: 403 },
);
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(
user,
module,
action,
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 }) => {
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
};
export const handleError: HandleServerError = ({
error,
event,
status,
message,
}) => {
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
View File
@@ -1,50 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase,
} from './agents.js';
describe('display names', () => {
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
});
describe('publicProfileSlugBase', () => {
it('creates readable normalized slugs', () => {
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
.toBe('aewyn-prime');
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
.toBe('élite-装備');
});
it('creates readable normalized slugs', () => {
expect(
publicProfileSlugBase(
' Aewyn Prime ',
'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', () => {
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
.toBe('agent-12345678');
});
it('uses a stable fallback when a name has no letters or numbers', () => {
expect(
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
).toBe('agent-12345678');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000'
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(
publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000',
),
).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
});
describe('agentProfileHref', () => {
it('prefers the public slug and safely encodes it', () => {
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
});
it('prefers the public slug and safely encodes it', () => {
expect(
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', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
it('falls back to the UUID for unmigrated records', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
});
+25 -21
View File
@@ -1,36 +1,40 @@
export type AgentProfileReference = {
id: string;
publicSlug?: string | null;
id: string;
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 {
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
}
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 {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.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 publicProfileSlugBase(
displayName: string,
userId: string,
): string {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.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(
agent: AgentProfileReference,
fragment = ''
agent: AgentProfileReference,
fragment = '',
): string {
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
}
+78 -78
View File
@@ -1,120 +1,120 @@
import type {
AiMechanicQuestion,
AiMechanicResponseMeta
AiMechanicQuestion,
AiMechanicResponseMeta,
} from './mechanic-authoring';
export const AI_CALCULATION_STAGE_OPERATORS = [
'base_damage',
'flat_add',
'additive_percent',
'multiplicative_percent',
'expected_critical',
'expected_headshot',
'rate_of_fire',
'magazine_size',
'reload_speed',
'firing_cycle'
'base_damage',
'flat_add',
'additive_percent',
'multiplicative_percent',
'expected_critical',
'expected_headshot',
'rate_of_fire',
'magazine_size',
'reload_speed',
'firing_cycle',
] as const;
export const AI_SKILL_CALCULATION_STAGES = [
'engineering',
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration'
'engineering',
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration',
] as const;
export type AiCalculationStageDraft = {
stageKey: string;
label: string;
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
displayOrder: number;
lockedPosition: 'first' | 'last' | null;
stageKey: string;
label: string;
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
displayOrder: number;
lockedPosition: 'first' | 'last' | null;
};
export type AiCalculationProfileDraft = {
statAttributeId: string;
stageKey: string;
weight: number;
softCap: number | null;
hardCap: number | null;
curveType: 'none' | 'power' | 'smoothstep';
curveParameter: number | null;
weaponCategory: string;
statAttributeId: string;
stageKey: string;
weight: number;
softCap: number | null;
hardCap: number | null;
curveType: 'none' | 'power' | 'smoothstep';
curveParameter: number | null;
weaponCategory: string;
};
export type AiSkillCalculationProfileDraft = {
statAttributeId: string;
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
operator: 'add' | 'multiply';
weight: number;
statAttributeId: string;
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
operator: 'add' | 'multiply';
weight: number;
};
export type AiCalculationRevisionDraft = {
name: string;
notes: string;
stages: AiCalculationStageDraft[];
profiles: AiCalculationProfileDraft[];
skillProfiles: AiSkillCalculationProfileDraft[];
name: string;
notes: string;
stages: AiCalculationStageDraft[];
profiles: AiCalculationProfileDraft[];
skillProfiles: AiSkillCalculationProfileDraft[];
};
export type AiCalculationTranscriptEntry = {
questionId: string;
question: string;
answer: string;
questionId: string;
question: string;
answer: string;
};
export type AiCalculationAuthoringRequest = {
instructions: string;
baseRevisionId: string | null;
currentDraft: AiCalculationRevisionDraft;
transcript: AiCalculationTranscriptEntry[];
instructions: string;
baseRevisionId: string | null;
currentDraft: AiCalculationRevisionDraft;
transcript: AiCalculationTranscriptEntry[];
};
export type AiCalculationQuestion = AiMechanicQuestion;
export type AiCalculationCapabilityGap = {
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
};
export type AiCalculationClarificationResponse = {
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
meta: AiMechanicResponseMeta;
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
meta: AiMechanicResponseMeta;
};
export type AiCalculationDraftResponse = {
status: 'draft_ready';
draft: AiCalculationRevisionDraft;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
status: 'draft_ready';
draft: AiCalculationRevisionDraft;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
};
export type AiCalculationCapabilityGapResponse = {
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiCalculationCapabilityGap[];
meta: AiMechanicResponseMeta;
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiCalculationCapabilityGap[];
meta: AiMechanicResponseMeta;
};
export type AiCalculationAuthoringResponse =
| AiCalculationClarificationResponse
| AiCalculationDraftResponse
| AiCalculationCapabilityGapResponse;
| AiCalculationClarificationResponse
| AiCalculationDraftResponse
| AiCalculationCapabilityGapResponse;
+100 -97
View File
@@ -1,140 +1,143 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
export const AI_MECHANIC_SOURCE_KINDS = [
'talent',
'exotic_weapon_talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank'
'talent',
'exotic_weapon_talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const;
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
export const AI_MECHANIC_SOURCE_PROFILES = {
talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
},
exotic_weapon_talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
},
os_protocol_talent: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: true
},
skill_chip_talent: {
variantKeys: ['minimum', 'maximum'],
allowAssignedAbility: true,
allowProtocolUpgradeScaling: false
},
specialization_tree_rank: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
}
} as const satisfies Record<AiMechanicSourceKind, {
variantKeys: readonly string[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
}>;
talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
exotic_weapon_talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
os_protocol_talent: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: true,
},
skill_chip_talent: {
variantKeys: ['minimum', 'maximum'],
allowAssignedAbility: true,
allowProtocolUpgradeScaling: false,
},
specialization_tree_rank: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
} as const satisfies Record<
AiMechanicSourceKind,
{
variantKeys: readonly string[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
}
>;
export type AiMechanicContext = {
sourceKind: AiMechanicSourceKind;
sourceName: string;
sourceDescription: string;
sourceKind: AiMechanicSourceKind;
sourceName: string;
sourceDescription: string;
};
export type AiMechanicValueColumn = {
key: string;
variantKey: string;
label: string;
key: string;
variantKey: string;
label: string;
};
export type AiMechanicTranscriptEntry = {
questionId: string;
question: string;
answer: string | number | boolean;
questionId: string;
question: string;
answer: string | number | boolean;
};
export type AiMechanicAuthoringRequest = {
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
};
export type AiMechanicQuestion = {
id: string;
question: string;
reason: string;
answerType: 'text' | 'number' | 'choice' | 'boolean';
options: string[];
id: string;
question: string;
reason: string;
answerType: 'text' | 'number' | 'choice' | 'boolean';
options: string[];
};
export type AiMechanicResponseMeta = {
requestId: string;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
requestId: string;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
export type AiMechanicClarificationResponse = {
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
meta: AiMechanicResponseMeta;
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
meta: AiMechanicResponseMeta;
};
export type AiMechanicDraftResponse = {
status: 'draft_ready';
draft: ConditionalMechanicDraftV1;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
status: 'draft_ready';
draft: ConditionalMechanicDraftV1;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
};
export type AiMechanicCapabilityGap = {
area:
| 'event'
| 'predicate'
| 'reducer'
| 'mutation'
| 'outcome'
| 'scope'
| 'value_reference'
| 'source_variant'
| 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
area:
| 'event'
| 'predicate'
| 'reducer'
| 'mutation'
| 'outcome'
| 'scope'
| 'value_reference'
| 'source_variant'
| 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
};
export type AiMechanicCapabilityGapResponse = {
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiMechanicCapabilityGap[];
meta: AiMechanicResponseMeta;
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiMechanicCapabilityGap[];
meta: AiMechanicResponseMeta;
};
export type AiMechanicAuthoringResponse =
| AiMechanicClarificationResponse
| AiMechanicDraftResponse
| AiMechanicCapabilityGapResponse;
| AiMechanicClarificationResponse
| AiMechanicDraftResponse
| AiMechanicCapabilityGapResponse;
export type AiMechanicAuthoringError = {
message: string;
message: string;
};
+64 -56
View File
@@ -1,66 +1,74 @@
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 = {
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription: 'Every third critical hit transfers the mark to a nearby enemy.'
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [{
questionId: 'transfer-target',
question: 'How is the next target selected?',
answer: 'Nearest enemy'
}],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a nearby transfer target.',
assumptions: ['The third hit consumes the current mark.'],
warnings: ['Target selection must be deterministic.'],
gaps: [{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'The event has no ordered nearby-target collection.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null
}],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150
}
}
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription:
'Every third critical hit transfers the mark to a nearby enemy.',
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [
{
questionId: 'transfer-target',
question: 'How is the next target selected?',
answer: 'Nearest enemy',
},
],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a nearby transfer target.',
assumptions: ['The third hit consumes the current mark.'],
warnings: ['Target selection must be deterministic.'],
gaps: [
{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'The event has no ordered nearby-target collection.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null,
},
],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
},
},
};
describe('mechanic developer handoff prompt', () => {
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
const prompt = buildMechanicDeveloperPrompt(handoff);
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
const prompt = buildMechanicDeveloperPrompt(handoff);
expect(prompt).toContain(handoff.context.sourceDescription);
expect(prompt).toContain('How is the next target selected?');
expect(prompt).toContain('Answer: Nearest enemy');
expect(prompt).toContain('Add a deterministic nearby-target selector.');
expect(prompt).toContain('Assistant request ID: req_gap_123');
expect(prompt).toContain('Persistence round-trips the new representation');
});
expect(prompt).toContain(handoff.context.sourceDescription);
expect(prompt).toContain('How is the next target selected?');
expect(prompt).toContain('Answer: Nearest enemy');
expect(prompt).toContain('Add a deterministic nearby-target selector.');
expect(prompt).toContain('Assistant request ID: req_gap_123');
expect(prompt).toContain('Persistence round-trips the new representation');
});
it('omits empty optional sections', () => {
const prompt = buildMechanicDeveloperPrompt({
...handoff,
transcript: [],
response: { ...handoff.response, assumptions: [], warnings: [] }
});
it('omits empty optional sections', () => {
const prompt = buildMechanicDeveloperPrompt({
...handoff,
transcript: [],
response: { ...handoff.response, assumptions: [], warnings: [] },
});
expect(prompt).not.toContain('## Clarification history');
expect(prompt).not.toContain('## Assumptions');
expect(prompt).not.toContain('## Warnings');
});
expect(prompt).not.toContain('## Clarification history');
expect(prompt).not.toContain('## Assumptions');
expect(prompt).not.toContain('## Warnings');
});
});
+42 -27
View File
@@ -1,40 +1,55 @@
import type {
AiMechanicCapabilityGapResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn
AiMechanicCapabilityGapResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn,
} from '$lib/ai/mechanic-authoring';
export type AiMechanicDeveloperHandoff = {
context: AiMechanicContext;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
response: AiMechanicCapabilityGapResponse;
context: AiMechanicContext;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
response: AiMechanicCapabilityGapResponse;
};
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) {
const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff;
const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', ');
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'));
export function buildMechanicDeveloperPrompt(
handoff: AiMechanicDeveloperHandoff,
) {
const {
context,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript,
response,
} = handoff;
const variants = valueColumns
.map((column) => `${column.label} (${column.variantKey})`)
.join(', ');
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.
+112
View File
@@ -0,0 +1,112 @@
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('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();
});
});
+280
View File
@@ -0,0 +1,280 @@
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 };
};
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;
}
};
function parseMarks(value: unknown): 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'])) return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
}
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);
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 (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');
}
+71
View File
@@ -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
View File
@@ -1,29 +1,89 @@
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: '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' }
{
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: '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;
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] {
return AVATAR_PRESETS.some((preset) => preset.id === value);
export function isAvatarPreset(
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 {
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`;
export function avatarUrl(
source: unknown,
value: unknown,
userId: string,
updatedAt?: unknown,
): string {
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`;
}
+84
View File
@@ -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 });
});
});
+72
View File
@@ -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
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
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: [],
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',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
}),
]);
});
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('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent';
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: null,
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
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,
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
description: 'Gain 20% for 3 Shots.',
rule,
}),
]);
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.',
);
});
});
+396
View File
@@ -0,0 +1,396 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: 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: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
) {
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[] = [];
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: Number(item.tier),
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
),
});
}
}
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`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
>();
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;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, 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}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? 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 ?? '',
),
),
activeBonuses,
equippedTalents,
};
}
+59 -57
View File
@@ -4,76 +4,78 @@ import { createDraft, parseLoadoutDraft } from './loadout.js';
const copyDraft = () => structuredClone(createDraft());
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
expect(draft.gear.Holster.level).toBe(50);
});
expect(parsed).toEqual(draft);
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', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
retired.chips = (retired.chips as unknown[]).slice(0, 3);
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
retired.chips = (retired.chips as unknown[]).slice(0, 3);
expect(parseLoadoutDraft(retired)).toBeNull();
});
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
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;
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();
});
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
}
};
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);
});
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();
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();
});
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 = [];
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();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects malformed nested equipment selections', () => {
const draft = copyDraft();
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
it('rejects malformed nested equipment selections', () => {
const draft = copyDraft();
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
expect(parseLoadoutDraft(draft)).toBeNull();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
});
+462 -227
View File
@@ -1,279 +1,514 @@
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 WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: 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 WeaponSelection = {
itemId: string;
level: number;
tier: number;
damage: number;
firepower: number;
toughness: number;
engineering: number;
talent1Id: string;
talent2Id: string;
rolls: [Roll, Roll];
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
primary: Roll;
secondary: [Roll, Roll];
};
export type ProtocolSelection = {
itemId: string;
level: number;
talentUpgrades: number;
};
export type CombatProfile = {
encounterSeconds: number;
hitRate: number;
headshotRate: number;
shotsPerBurst: number;
burstPauseSeconds: number;
sameTargetSeconds: number;
distinctTargetsPerMinute: number;
killsPerMinute: number;
headshotKillRate: number;
damageTakenPerMinute: number;
swapsPerMinute: number;
targetDistanceMeters: number;
healthPercent: number;
extraHealthActive: boolean;
encounterSeconds: number;
hitRate: number;
headshotRate: number;
shotsPerBurst: number;
burstPauseSeconds: number;
sameTargetSeconds: number;
distinctTargetsPerMinute: number;
killsPerMinute: number;
headshotKillRate: number;
damageTakenPerMinute: number;
swapsPerMinute: number;
targetDistanceMeters: number;
healthPercent: number;
extraHealthActive: boolean;
};
export type CalculationMode = 'maximum' | 'custom';
export type EffectMode = 'off' | 'peak' | 'encounter';
export type ConditionalEffectSelection = {
mode: EffectMode;
overrides?: Partial<CombatProfile>;
inputs?: Record<string, number | boolean>;
mode: EffectMode;
overrides?: Partial<CombatProfile>;
inputs?: Record<string, number | boolean>;
};
export type SkillActivationSelection = {
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
usesPerMinute?: number;
targets: number;
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
usesPerMinute?: number;
targets: number;
};
export type SpecializationSelection = {
focusId: string;
nodeRanks: Record<string, number>;
focusId: string;
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
protocol: ProtocolSelection;
conditionalEffects: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile;
skillActivations: Record<string, SkillActivationSelection>;
specialization: SpecializationSelection;
version: 4;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
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 blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', 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: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
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 blankWeapon = (): WeaponSelection => ({
itemId: '',
level: 50,
tier: 0,
damage: 0,
firepower: 0,
toughness: 0,
engineering: 0,
talent1Id: '',
talent2Id: '',
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
hitRate: 75,
headshotRate: 20,
shotsPerBurst: 30,
burstPauseSeconds: 0,
sameTargetSeconds: 5,
distinctTargetsPerMinute: 12,
killsPerMinute: 4,
headshotKillRate: 20,
damageTakenPerMinute: 6,
swapsPerMinute: 2,
targetDistanceMeters: 15,
healthPercent: 100,
extraHealthActive: false
encounterSeconds: 60,
hitRate: 75,
headshotRate: 20,
shotsPerBurst: 30,
burstPauseSeconds: 0,
sameTargetSeconds: 5,
distinctTargetsPerMinute: 12,
killsPerMinute: 4,
headshotKillRate: 20,
damageTakenPerMinute: 6,
swapsPerMinute: 2,
targetDistanceMeters: 15,
healthPercent: 100,
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
conditionalEffects: {},
combatProfile: defaultCombatProfile(),
skillActivations: {},
specialization: {
focusId: '',
nodeRanks: {}
}
version: 4,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
conditionalEffects: {},
combatProfile: defaultCombatProfile(),
skillActivations: {},
specialization: {
focusId: '',
nodeRanks: {},
},
});
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[]) =>
Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
Object.keys(value).length === keys.length &&
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
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 =>
isRecord(value)
&& hasExactKeys(value, ['statId', 'value'])
&& typeof value.statId === 'string'
&& isFiniteNumber(value.value);
isRecord(value) &&
hasExactKeys(value, ['statId', 'value']) &&
typeof value.statId === 'string' &&
isFiniteNumber(value.value);
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 =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls)
&& Array.isArray(value.augmentSlots)
&& value.augmentSlots.every(isFiniteNumber);
isRecord(value) &&
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
['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 =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls);
isRecord(value) &&
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent2Id',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value)
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
&& typeof value.setId === 'string'
&& (value.quality === 'Superior' || value.quality === 'High-end')
&& isRoll(value.primary)
&& isRollPair(value.secondary);
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
isRoll(value.primary) &&
isRollPair(value.secondary);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
typeof value.itemId === 'string' &&
isFiniteNumber(value.level) &&
isFiniteNumber(value.talentUpgrades);
const isCombatProfile = (value: unknown): value is CombatProfile =>
isRecord(value)
&& hasExactKeys(value, [
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
])
&& [
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
].every((key) => isFiniteNumber(value[key]))
&& typeof value.extraHealthActive === 'boolean';
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) && Object.values(value).every((entry) => {
if (!isRecord(entry)
|| !['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))
);
isRecord(value) &&
hasExactKeys(value, [
'encounterSeconds',
'hitRate',
'headshotRate',
'shotsPerBurst',
'burstPauseSeconds',
'sameTargetSeconds',
'distinctTargetsPerMinute',
'killsPerMinute',
'headshotKillRate',
'damageTakenPerMinute',
'swapsPerMinute',
'targetDistanceMeters',
'healthPercent',
'extraHealthActive',
]) &&
[
'encounterSeconds',
'hitRate',
'headshotRate',
'shotsPerBurst',
'burstPauseSeconds',
'sameTargetSeconds',
'distinctTargetsPerMinute',
'killsPerMinute',
'headshotKillRate',
'damageTakenPerMinute',
'swapsPerMinute',
'targetDistanceMeters',
'healthPercent',
].every((key) => isFiniteNumber(value[key])) &&
typeof value.extraHealthActive === 'boolean';
const isEffectStates = (
value: unknown,
): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) &&
Object.values(value).every((entry) => {
if (
!isRecord(entry) ||
!['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 {
if (!isRecord(value)
|| !hasExactKeys(value, [
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
])
|| value.version !== 4
|| typeof value.buildName !== 'string'
|| !['maximum', 'custom'].includes(String(value.calculationMode))) return null;
const gear = value.gear;
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;
if (
!isRecord(value) ||
!hasExactKeys(value, [
'version',
'buildName',
'calculationMode',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
return null;
const gear = value.gear;
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) => {
const low = Number(min), high = Number(max), 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));
const low = Number(min),
high = Number(max),
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) =>
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
export const scaled = (
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 = {
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
};
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level);
export const gearMultiplier = (level: unknown) =>
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 {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target);
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 gearBaseStatRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin =
(Number.isFinite(configuredMin) ? configuredMin : 0) *
gearMultiplier(target);
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 {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target);
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 weaponBaseRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin =
(Number.isFinite(configuredMin) ? configuredMin : 0) *
weaponMultiplier(target);
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(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon'
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon',
): LevelScaledRange {
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
};
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional:
source === 'gear'
? target < 10 || reference < 10
: target < 40 || reference < 40,
};
}
export function remapRangeValue(value: unknown, from: LevelScaledRange, 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);
export function remapRangeValue(
value: unknown,
from: LevelScaledRange,
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);
}
+59 -32
View File
@@ -1,39 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
formatItemDamageRangeScript,
formatWeaponCalculationScript
formatItemDamageRangeScript,
formatWeaponCalculationScript,
} from './calculation-script';
describe('formatWeaponCalculationScript', () => {
it('starts from the selected equipped damage and explains ordered typed stages', () => {
const lines = formatWeaponCalculationScript({
revision: { version: 4, name: 'Resurgence model' },
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: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
],
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('starts from the selected equipped damage and explains ordered typed stages', () => {
const lines = formatWeaponCalculationScript({
revision: { version: 4, name: 'Resurgence model' },
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: 'cycle',
label: 'Firing cycle',
operator: 'firing_cycle',
display_order: 3,
},
],
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', () => {
const lines = formatItemDamageRangeScript();
expect(lines).toContain('SCALE configured_damage_range 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');
});
it('keeps level scaling in item configuration instead of applying it twice', () => {
const lines = formatItemDamageRangeScript();
expect(lines).toContain(
'SCALE configured_damage_range 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',
);
});
});
+120 -104
View File
@@ -1,131 +1,147 @@
export type CalculationScriptStage = {
stage_key: string;
label: string;
operator: string;
display_order: number;
stage_key: string;
label: string;
operator: string;
display_order: number;
};
export type CalculationScriptProfile = {
stat_attribute_id: string;
stage_key: string;
weight?: number | null;
soft_cap?: number | null;
hard_cap?: number | null;
curve_type?: string | null;
curve_parameter?: number | null;
weapon_category?: string | null;
stat_attribute_id: string;
stage_key: string;
weight?: number | null;
soft_cap?: number | null;
hard_cap?: number | null;
curve_type?: string | null;
curve_parameter?: number | null;
weapon_category?: string | null;
};
export type CalculationScriptAttribute = {
id: string;
name: string;
unit?: string | null;
id: string;
name: string;
unit?: string | null;
};
export type CalculationScriptRevision = {
version?: number | null;
name?: string | null;
version?: number | null;
name?: string | null;
};
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 ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unnamed';
const identifier = (value: unknown) =>
String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unnamed';
function profileExpression(
profile: CalculationScriptProfile,
attributes: Map<string, CalculationScriptAttribute>
profile: CalculationScriptProfile,
attributes: Map<string, CalculationScriptAttribute>,
) {
const attribute = attributes.get(profile.stat_attribute_id);
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
const weight = Number(profile.weight ?? 1);
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
if (weight !== 1) expression += ` × ${number(weight)}`;
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
const curve = 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}`;
}
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()}`;
}
return expression;
const attribute = attributes.get(profile.stat_attribute_id);
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
const weight = Number(profile.weight ?? 1);
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
if (weight !== 1) expression += ` × ${number(weight)}`;
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
const curve =
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}`;
}
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()}`;
}
return expression;
}
const operatorStatement = (operator: string, label: string, sources: string[]) => {
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
switch (operator) {
case 'base_damage':
return ['TAKE equipped_weapon.damage AS base_damage'];
case 'flat_add':
return [`ADD ${source} TO damage`];
case 'additive_percent':
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
case 'multiplicative_percent':
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
case 'expected_critical':
return [
`WEIGHT critical_damage BY critical_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'expected_headshot':
return [
`WEIGHT headshot_damage BY headshot_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'rate_of_fire':
return [`ADJUST equipped_weapon.rpm USING ${source}`];
case 'magazine_size':
return [`ADJUST equipped_weapon.magazine USING ${source}`];
case 'reload_speed':
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
case 'firing_cycle':
return [
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
'RETURN expected_damage_per_second'
];
default:
return [`APPLY ${identifier(label)} USING ${source}`];
}
const operatorStatement = (
operator: string,
label: string,
sources: string[],
) => {
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
switch (operator) {
case 'base_damage':
return ['TAKE equipped_weapon.damage AS base_damage'];
case 'flat_add':
return [`ADD ${source} TO damage`];
case 'additive_percent':
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
case 'multiplicative_percent':
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
case 'expected_critical':
return [
`WEIGHT critical_damage BY critical_chance`,
...(sources.length ? [`SOURCE ${source}`] : []),
];
case 'expected_headshot':
return [
`WEIGHT headshot_damage BY headshot_chance`,
...(sources.length ? [`SOURCE ${source}`] : []),
];
case 'rate_of_fire':
return [`ADJUST equipped_weapon.rpm USING ${source}`];
case 'magazine_size':
return [`ADJUST equipped_weapon.magazine USING ${source}`];
case 'reload_speed':
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
case 'firing_cycle':
return [
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
'RETURN expected_damage_per_second',
];
default:
return [`APPLY ${identifier(label)} USING ${source}`];
}
};
export function formatWeaponCalculationScript(input: {
revision?: CalculationScriptRevision | null;
stages: CalculationScriptStage[];
profiles: CalculationScriptProfile[];
attributes: CalculationScriptAttribute[];
revision?: CalculationScriptRevision | null;
stages: CalculationScriptStage[];
profiles: CalculationScriptProfile[];
attributes: CalculationScriptAttribute[];
}) {
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
const lines = [
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
];
for (const stage of [...input.stages].sort(
(left, right) => Number(left.display_order) - Number(right.display_order)
)) {
const sources = input.profiles
.filter((profile) => profile.stage_key === stage.stage_key)
.map((profile) => profileExpression(profile, attributes));
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
}
return lines;
const attributes = new Map(
input.attributes.map((attribute) => [String(attribute.id), attribute]),
);
const lines = [
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`,
];
for (const stage of [...input.stages].sort(
(left, right) => Number(left.display_order) - Number(right.display_order),
)) {
const sources = input.profiles
.filter((profile) => profile.stage_key === stage.stage_key)
.map((profile) => profileExpression(profile, attributes));
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
lines.push(
...operatorStatement(
String(stage.operator),
String(stage.label),
sources,
),
);
}
return lines;
}
export function formatItemDamageRangeScript() {
return [
'CONFIGURE equipped_weapon',
'READ configured_damage_range AT level_1',
'SCALE configured_damage_range TO equipped_weapon.level',
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
'PASS equipped_weapon.damage TO combat_calculation'
];
return [
'CONFIGURE equipped_weapon',
'READ configured_damage_range AT level_1',
'SCALE configured_damage_range TO equipped_weapon.level',
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
'PASS equipped_weapon.damage TO combat_calculation',
];
}
+221 -126
View File
@@ -1,141 +1,236 @@
import { describe, expect, it } from 'vitest';
import {
calculateCombat,
expectedWeaponShotDamage,
type CombatCalculationInput
calculateCombat,
expectedWeaponShotDamage,
type CombatCalculationInput,
} from './combat.js';
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
attributes: { 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
const base = (
overrides: Partial<CombatCalculationInput> = {},
): CombatCalculationInput => ({
weapon: {
baseDamage: 100,
rpm: 600,
magazine: 10,
reloadSeconds: 2,
headshotMultiplier: 2,
},
attributes: {
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', () => {
it('calculates four hit values and reload-aware DPS', () => {
const result = calculateCombat(base());
expect(result.status).toBe('OK');
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
expect(result.burstDps).toBe(1485);
expect(result.sustainedDps).toBeCloseTo(495);
});
it('calculates four hit values and reload-aware DPS', () => {
const result = calculateCombat(base());
expect(result.status).toBe('OK');
expect(result.hitValues).toEqual({
normal: 132,
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', () => {
const input = base({
attributes: { critChance: 50, critDamage: 100 },
bindings: [
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
{ 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('calculates expected body and headshot damage for timeline events', () => {
const input = base({
attributes: { critChance: 50, critDamage: 100 },
bindings: [
{
attributeId: 'critChance',
stage: 'critical_chance',
operator: 'add',
},
{
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', () => {
const result = calculateCombat(base({
attributes: { weapon: 100 },
bindings: [{
attributeId: 'weapon',
stage: 'weapon_damage',
operator: 'add',
weight: 2,
softCap: 50,
hardCap: 80,
curveType: 'power',
curveParameter: 1
}]
}));
expect(result.hitValues?.normal).toBe(180);
});
it('applies weights, hard caps, and a configured curve safely', () => {
const result = calculateCombat(
base({
attributes: { weapon: 100 },
bindings: [
{
attributeId: 'weapon',
stage: 'weapon_damage',
operator: 'add',
weight: 2,
softCap: 50,
hardCap: 80,
curveType: 'power',
curveParameter: 1,
},
],
}),
);
expect(result.hitValues?.normal).toBe(180);
});
it('applies firing-cycle attributes to burst and sustained DPS', () => {
const result = calculateCombat(base({
attributes: { rate: 20, magazine: 100, reload: 100 },
bindings: [
{ attributeId: 'rate', stage: 'rate_of_fire', 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);
});
it('applies firing-cycle attributes to burst and sustained DPS', () => {
const result = calculateCombat(
base({
attributes: { rate: 20, magazine: 100, reload: 100 },
bindings: [
{ attributeId: 'rate', stage: 'rate_of_fire', 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);
});
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
const stackingEffects = [{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3, critDamage: 1 }
}];
const off = calculateCombat(base({ stackingEffects }));
const peak = calculateCombat(base({
stackingEffects,
assumptions: { 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('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
const stackingEffects = [
{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3, critDamage: 1 },
},
];
const off = calculateCombat(base({ stackingEffects }));
const peak = calculateCombat(
base({
stackingEffects,
assumptions: {
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', () => {
const result = calculateCombat(base({
stackingEffects: [{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3 }
}],
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('models encounter stack ramp and refresh with the weapon firing cycle', () => {
const result = calculateCombat(
base({
stackingEffects: [
{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: {
maxStacks: 10,
durationSeconds: 10,
stacksPerTrigger: 1,
},
attributes: { weapon: 3 },
},
],
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', () => {
const result = calculateCombat(base({
stackingEffects: [{
id: 'slow',
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
lifecycle: { maxStacks: 10, durationSeconds: 10 },
attributes: { weapon: 3 }
}],
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
}));
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
});
it('does not trigger when expected hits cannot meet the trigger window', () => {
const result = calculateCombat(
base({
stackingEffects: [
{
id: 'slow',
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
lifecycle: { maxStacks: 10, durationSeconds: 10 },
attributes: { weapon: 3 },
},
],
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', () => {
const result = calculateCombat(base({
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
assumptions: { mode: 'encounter' },
stackingEffects: [{
id: 'need-profile',
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',
'assumptions.hitRate',
'assumptions.firingActivity',
'assumptions.encounterSeconds'
]));
});
it('returns explicit requirements instead of partial results', () => {
const result = calculateCombat(
base({
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
assumptions: { mode: 'encounter' },
stackingEffects: [
{
id: 'need-profile',
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',
'assumptions.hitRate',
'assumptions.firingActivity',
'assumptions.encounterSeconds',
]),
);
});
});
+525 -390
View File
@@ -1,465 +1,600 @@
export type CalculationStage =
| 'flat_damage'
| 'weapon_damage'
| 'critical_chance'
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| 'rate_of_fire'
| 'magazine_size'
| 'reload_speed'
| 'final_damage';
| 'flat_damage'
| 'weapon_damage'
| 'critical_chance'
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| 'rate_of_fire'
| 'magazine_size'
| 'reload_speed'
| 'final_damage';
export type CalculationOperator = 'add' | 'multiply';
export type CurveType = 'none' | 'power' | 'smoothstep';
export type AttributeBinding = {
attributeId: string;
stage: CalculationStage;
operator: CalculationOperator;
weight?: number;
softCap?: number | null;
hardCap?: number | null;
curveType?: CurveType;
curveParameter?: number | null;
attributeId: string;
stage: CalculationStage;
operator: CalculationOperator;
weight?: number;
softCap?: number | null;
hardCap?: number | null;
curveType?: CurveType;
curveParameter?: number | null;
};
export type StatValues = Record<string, number>;
export type WeaponCombatInput = {
baseDamage?: number;
rpm?: number;
magazine?: number;
reloadSeconds?: number;
headshotMultiplier?: number;
baseDamage?: number;
rpm?: number;
magazine?: number;
reloadSeconds?: number;
headshotMultiplier?: number;
};
export type WeaponFiringCycle = {
rpm: number;
magazine: number;
reloadSeconds: number;
rpm: number;
magazine: number;
reloadSeconds: number;
};
export type StackingMode = 'off' | 'peak' | 'encounter';
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 = {
id: string;
trigger: TriggerRule;
lifecycle: StackLifecycle;
attributes: StatValues;
id: string;
trigger: TriggerRule;
lifecycle: StackLifecycle;
attributes: StatValues;
};
export type CombatAssumptions = {
mode: StackingMode;
hitRate?: number;
headshotRate?: number;
firingActivity?: number;
encounterSeconds?: number;
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
mode: StackingMode;
hitRate?: number;
headshotRate?: number;
firingActivity?: number;
encounterSeconds?: number;
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
};
export type MissingRequirement =
| 'weapon.baseDamage'
| 'weapon.rpm'
| 'weapon.magazine'
| 'weapon.reloadSeconds'
| 'assumptions.hitRate'
| 'assumptions.firingActivity'
| 'assumptions.encounterSeconds'
| `binding:${string}`;
| 'weapon.baseDamage'
| 'weapon.rpm'
| 'weapon.magazine'
| 'weapon.reloadSeconds'
| 'assumptions.hitRate'
| 'assumptions.firingActivity'
| 'assumptions.encounterSeconds'
| `binding:${string}`;
export type DamageValues = {
normal: number;
critical: number;
headshot: number;
criticalHeadshot: number;
normal: number;
critical: number;
headshot: number;
criticalHeadshot: number;
};
export type StackSummary = {
effectId: string;
mode: StackingMode;
stacksAtEnd: number;
maxStacksReached: number;
buildUpSeconds: number | null;
averageStacks: number;
maintained: boolean;
effectId: string;
mode: StackingMode;
stacksAtEnd: number;
maxStacksReached: number;
buildUpSeconds: number | null;
averageStacks: number;
maintained: boolean;
};
export type CombatCalculation = {
status: 'OK' | 'INSUFFICIENT_DATA';
missing: MissingRequirement[];
hitValues: DamageValues | null;
burstDps: number | null;
sustainedDps: number | null;
encounterDps: number | null;
stackSummaries: StackSummary[];
status: 'OK' | 'INSUFFICIENT_DATA';
missing: MissingRequirement[];
hitValues: DamageValues | null;
burstDps: number | null;
sustainedDps: number | null;
encounterDps: number | null;
stackSummaries: StackSummary[];
};
export type CombatCalculationInput = {
weapon: WeaponCombatInput;
attributes: StatValues;
bindings: AttributeBinding[];
stageOrder?: CalculationStage[];
stackingEffects?: StackingEffect[];
assumptions: CombatAssumptions;
weapon: WeaponCombatInput;
attributes: StatValues;
bindings: AttributeBinding[];
stageOrder?: CalculationStage[];
stackingEffects?: StackingEffect[];
assumptions: CombatAssumptions;
};
type ResolvedAssumptions = {
mode: StackingMode;
hitRate: number;
headshotRate: number;
firingActivity: number;
encounterSeconds: number;
mode: StackingMode;
hitRate: number;
headshotRate: number;
firingActivity: number;
encounterSeconds: number;
};
type StackState = {
stacks: number;
expiresAt: number;
maxStacksReached: number;
buildUpSeconds: number | null;
stacks: number;
expiresAt: number;
maxStacksReached: number;
buildUpSeconds: number | null;
};
type TimedStackEffect = {
effect: StackingEffect;
assumptions: ResolvedAssumptions;
events: number[];
effect: StackingEffect;
assumptions: ResolvedAssumptions;
events: number[];
};
const EPSILON = 0.000_001;
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
const finiteNonNegative = (value: unknown) => 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;
const finitePositive = (value: unknown) =>
Number.isFinite(Number(value)) && Number(value) > 0;
const finiteNonNegative = (value: unknown) =>
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 {
const override = base.perEffect?.[effectId] ?? {};
return {
mode: override.mode ?? base.mode,
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 resolveAssumptions(
base: CombatAssumptions,
effectId: string,
): ResolvedAssumptions {
const override = base.perEffect?.[effectId] ?? {};
return {
mode: override.mode ?? base.mode,
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) {
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
const sign = raw < 0 ? -1 : 1;
let value = Math.abs(raw * weight);
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
const overflow = value - softCap;
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;
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
const sign = raw < 0 ? -1 : 1;
let value = Math.abs(raw * weight);
const softCap = finiteNonNegative(binding.softCap)
? Number(binding.softCap)
: null;
const hardCap = finiteNonNegative(binding.hardCap)
? Number(binding.hardCap)
: null;
if (
softCap !== null &&
value > softCap &&
binding.curveType &&
binding.curveType !== 'none'
) {
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
const overflow = value - softCap;
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) {
for (const [id, value] of Object.entries(incoming)) {
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
}
function addAttributes(
target: StatValues,
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[] {
const missing: MissingRequirement[] = [];
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
for (const binding of input.bindings) {
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
}
if (input.assumptions.mode === 'encounter') {
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
}
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)];
const missing: MissingRequirement[] = [];
if (!finitePositive(input.weapon.baseDamage))
missing.push('weapon.baseDamage');
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
if (
!Number.isInteger(input.weapon.magazine) ||
Number(input.weapon.magazine) < 1
)
missing.push('weapon.magazine');
if (!finiteNonNegative(input.weapon.reloadSeconds))
missing.push('weapon.reloadSeconds');
const boundIds = new Set(
input.bindings.map((binding) => binding.attributeId),
);
for (const binding of input.bindings) {
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0))
missing.push(`binding:${binding.attributeId}`);
}
if (input.assumptions.mode === 'encounter') {
if (!finitePositive(input.assumptions.hitRate))
missing.push('assumptions.hitRate');
if (!finitePositive(input.assumptions.firingActivity))
missing.push('assumptions.firingActivity');
if (!finitePositive(input.assumptions.encounterSeconds))
missing.push('assumptions.encounterSeconds');
}
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) {
let additive = 0;
let multiplicative = 1;
for (const binding of bindings.filter((item) => item.stage === stage)) {
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
if (binding.operator === 'add') additive += value;
else multiplicative *= value;
}
return { additive, multiplicative };
function stageValues(
attributes: StatValues,
bindings: AttributeBinding[],
stage: CalculationStage,
) {
let additive = 0;
let multiplicative = 1;
for (const binding of bindings.filter((item) => item.stage === stage)) {
const value = transformedValue(
Number(attributes[binding.attributeId] ?? 0),
binding,
);
if (binding.operator === 'add') additive += value;
else multiplicative *= value;
}
return { additive, multiplicative };
}
function damageBase(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[],
) {
let damage = weapon.baseDamage;
const order = stageOrder?.length
? stageOrder
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
for (const stage of order) {
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) 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;
let damage = weapon.baseDamage;
const order = stageOrder?.length
? stageOrder
: ([
'flat_damage',
'weapon_damage',
'final_damage',
] satisfies CalculationStage[]);
for (const stage of order) {
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage))
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(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[],
): DamageValues {
const normal = damageBase(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_damage');
const headshot = stageValues(attributes, bindings, 'headshot_damage');
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
return {
normal,
critical: normal * criticalFactor,
headshot: normal * headshotFactor,
criticalHeadshot: normal * criticalFactor * headshotFactor
};
const normal = damageBase(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_damage');
const headshot = stageValues(attributes, bindings, 'headshot_damage');
const criticalFactor =
(1 + critical.additive / 100) * critical.multiplicative;
const headshotFactor =
weapon.headshotMultiplier *
(1 + headshot.additive / 100) *
headshot.multiplicative;
return {
normal,
critical: normal * criticalFactor,
headshot: normal * headshotFactor,
criticalHeadshot: normal * criticalFactor * headshotFactor,
};
}
function dpsFor(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
assumptions: ResolvedAssumptions,
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
assumptions: ResolvedAssumptions,
stageOrder?: CalculationStage[],
) {
const values = hitValues(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
+ values.critical * boundCriticalChance * (1 - headshotChance)
+ values.headshot * (1 - boundCriticalChance) * headshotChance
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
weapon,
attributes,
bindings
);
const shotsPerSecond = rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
};
const values = hitValues(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
const configuredHeadshot = stageValues(
attributes,
bindings,
'headshot_chance',
);
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
const average =
values.normal * (1 - boundCriticalChance) * (1 - headshotChance) +
values.critical * boundCriticalChance * (1 - headshotChance) +
values.headshot * (1 - boundCriticalChance) * headshotChance +
values.criticalHeadshot * boundCriticalChance * headshotChance;
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
weapon,
attributes,
bindings,
);
const shotsPerSecond = rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained:
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
};
}
export function expectedWeaponShotDamage(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
headshot = false,
stageOrder?: CalculationStage[]
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
headshot = false,
stageOrder?: CalculationStage[],
) {
const resolvedWeapon: Required<WeaponCombatInput> = {
baseDamage: Number(weapon.baseDamage),
rpm: Number(weapon.rpm),
magazine: Number(weapon.magazine),
reloadSeconds: Number(weapon.reloadSeconds),
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
};
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const criticalChance = clamp(critical.additive / 100, 0, 1);
return headshot
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
const resolvedWeapon: Required<WeaponCombatInput> = {
baseDamage: Number(weapon.baseDamage),
rpm: Number(weapon.rpm),
magazine: Number(weapon.magazine),
reloadSeconds: Number(weapon.reloadSeconds),
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2),
};
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const criticalChance = clamp(critical.additive / 100, 0, 1);
return headshot
? values.headshot * (1 - criticalChance) +
values.criticalHeadshot * criticalChance
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
}
export function resolveWeaponFiringCycle(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[]
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
): WeaponFiringCycle {
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
return {
rpm: Number(weapon.rpm)
* (1 + rateOfFire.additive / 100)
* rateOfFire.multiplicative,
magazine: Math.max(
1,
Number(weapon.magazine)
* (1 + magazineSize.additive / 100)
* magazineSize.multiplicative
),
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
EPSILON,
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
)
};
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
return {
rpm:
Number(weapon.rpm) *
(1 + rateOfFire.additive / 100) *
rateOfFire.multiplicative,
magazine: Math.max(
1,
Number(weapon.magazine) *
(1 + magazineSize.additive / 100) *
magazineSize.multiplicative,
),
reloadSeconds:
Number(weapon.reloadSeconds) /
Math.max(
EPSILON,
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative,
),
};
}
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
let magazineRemaining = weapon.magazine;
let expectedHits = 0;
let counterStartedAt = 0;
const events: number[] = [];
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
if (magazineRemaining === 0) {
time += weapon.reloadSeconds;
magazineRemaining = weapon.magazine;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = 0;
counterStartedAt = time;
}
continue;
}
if (expectedHits === 0) counterStartedAt = time;
expectedHits += assumptions.hitRate;
magazineRemaining -= 1;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = assumptions.hitRate;
counterStartedAt = time;
}
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
events.push(time);
expectedHits = 0;
counterStartedAt = time;
}
time += shotInterval;
}
return events;
function triggerEvents(
weapon: Required<WeaponCombatInput>,
effect: StackingEffect,
assumptions: ResolvedAssumptions,
) {
if (
assumptions.mode !== 'encounter' ||
effect.trigger.hitsRequired < 1 ||
effect.trigger.windowSeconds <= 0
)
return [];
const shotInterval =
60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON);
let magazineRemaining = weapon.magazine;
let expectedHits = 0;
let counterStartedAt = 0;
const events: number[] = [];
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
if (magazineRemaining === 0) {
time += weapon.reloadSeconds;
magazineRemaining = weapon.magazine;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = 0;
counterStartedAt = time;
}
continue;
}
if (expectedHits === 0) counterStartedAt = time;
expectedHits += assumptions.hitRate;
magazineRemaining -= 1;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
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 {
if (effect.assumptions.mode === 'off') {
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
}
if (effect.assumptions.mode === 'peak') {
return {
stacks: effect.effect.lifecycle.maxStacks,
expiresAt: Infinity,
maxStacksReached: effect.effect.lifecycle.maxStacks,
buildUpSeconds: 0
};
}
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
for (const eventTime of effect.events) {
if (eventTime - time > EPSILON) break;
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
state.stacks = Math.min(
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;
if (effect.assumptions.mode === 'off') {
return {
stacks: 0,
expiresAt: 0,
maxStacksReached: 0,
buildUpSeconds: null,
};
}
if (effect.assumptions.mode === 'peak') {
return {
stacks: effect.effect.lifecycle.maxStacks,
expiresAt: Infinity,
maxStacksReached: effect.effect.lifecycle.maxStacks,
buildUpSeconds: 0,
};
}
const state: StackState = {
stacks: 0,
expiresAt: 0,
maxStacksReached: 0,
buildUpSeconds: null,
};
for (const eventTime of effect.events) {
if (eventTime - time > EPSILON) break;
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
state.stacks = Math.min(
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) {
const result = { ...base };
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
return result;
function stackAttributes(
base: StatValues,
effects: TimedStackEffect[],
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 {
const missing = requiredData(input);
if (missing.length) {
return {
status: 'INSUFFICIENT_DATA',
missing,
hitValues: null,
burstDps: null,
sustainedDps: null,
encounterDps: null,
stackSummaries: []
};
}
const weapon: Required<WeaponCombatInput> = {
baseDamage: Number(input.weapon.baseDamage),
rpm: Number(input.weapon.rpm),
magazine: Number(input.weapon.magazine),
reloadSeconds: Number(input.weapon.reloadSeconds),
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
};
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
});
const globalAssumptions = resolveAssumptions(input.assumptions, '');
const finalTime = Math.max(
globalAssumptions.encounterSeconds,
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
);
const current = dpsFor(
weapon,
stackAttributes(input.attributes, effects, finalTime),
input.bindings,
globalAssumptions,
input.stageOrder
);
let encounterDps: number | null = null;
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
let total = 0;
for (let index = 0; index < steps; index += 1) {
const time = finalTime * (index + 0.5) / steps;
const result = dpsFor(
weapon,
stackAttributes(input.attributes, effects, time),
input.bindings,
globalAssumptions,
input.stageOrder
);
total += result.sustained * globalAssumptions.firingActivity;
}
encounterDps = total / steps;
}
return {
status: 'OK',
missing: [],
hitValues: current.values,
burstDps: current.burst,
sustainedDps: current.sustained,
encounterDps,
stackSummaries: effects.map((item) => {
const end = stateAt(item, finalTime);
const duration = 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)
};
})
};
export function calculateCombat(
input: CombatCalculationInput,
): CombatCalculation {
const missing = requiredData(input);
if (missing.length) {
return {
status: 'INSUFFICIENT_DATA',
missing,
hitValues: null,
burstDps: null,
sustainedDps: null,
encounterDps: null,
stackSummaries: [],
};
}
const weapon: Required<WeaponCombatInput> = {
baseDamage: Number(input.weapon.baseDamage),
rpm: Number(input.weapon.rpm),
magazine: Number(input.weapon.magazine),
reloadSeconds: Number(input.weapon.reloadSeconds),
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
};
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
(effect) => {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
return {
effect,
assumptions,
events: triggerEvents(weapon, effect, assumptions),
};
},
);
const globalAssumptions = resolveAssumptions(input.assumptions, '');
const finalTime = Math.max(
globalAssumptions.encounterSeconds,
...effects
.filter((effect) => effect.assumptions.mode === 'encounter')
.map((effect) => effect.assumptions.encounterSeconds),
);
const current = dpsFor(
weapon,
stackAttributes(input.attributes, effects, finalTime),
input.bindings,
globalAssumptions,
input.stageOrder,
);
let encounterDps: number | null = null;
if (
effects.some((effect) => effect.assumptions.mode === 'encounter') &&
finalTime > 0
) {
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
let total = 0;
for (let index = 0; index < steps; index += 1) {
const time = (finalTime * (index + 0.5)) / steps;
const result = dpsFor(
weapon,
stackAttributes(input.attributes, effects, time),
input.bindings,
globalAssumptions,
input.stageOrder,
);
total += result.sustained * globalAssumptions.firingActivity;
}
encounterDps = total / steps;
}
return {
status: 'OK',
missing: [],
hitValues: current.values,
burstDps: current.burst,
sustainedDps: current.sustained,
encounterDps,
stackSummaries: effects.map((item) => {
const end = stateAt(item, finalTime);
const duration =
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),
};
}),
};
}
+533 -375
View File
@@ -1,413 +1,571 @@
import { describe, expect, it } from 'vitest';
import {
TRIGGER_ACTION_REGISTRY,
evaluateConditionalProgram,
summarizeTrigger,
validateConditionalProgram,
validateTriggerConfiguration,
type ConditionalEvent,
type ConditionalProgram,
type TriggerConfiguration
TRIGGER_ACTION_REGISTRY,
evaluateConditionalProgram,
summarizeTrigger,
validateConditionalProgram,
validateTriggerConfiguration,
type ConditionalEvent,
type ConditionalProgram,
type TriggerConfiguration,
} from './conditional.js';
const modifier = {
id: 'weapon-damage',
scope: 'equipped_weapon' as const,
application: 'active_modifier' as const,
attributeId: 'weapon-damage',
value: 3
id: 'weapon-damage',
scope: 'equipped_weapon' as const,
application: 'active_modifier' as const,
attributeId: 'weapon-damage',
value: 3,
};
const program = (
trigger: TriggerConfiguration,
overrides: Partial<ConditionalProgram> = {}
trigger: TriggerConfiguration,
overrides: Partial<ConditionalProgram> = {},
): ConditionalProgram => ({
id: 'test-effect',
trigger,
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 10,
stacksPerActivation: 1
},
outcomes: [modifier],
...overrides
id: 'test-effect',
trigger,
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 10,
stacksPerActivation: 1,
},
outcomes: [modifier],
...overrides,
});
const shot = (
at: number,
targetId: string | null,
hit = true,
headshot = false
at: number,
targetId: string | null,
hit = true,
headshot = false,
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
describe('trigger action registry', () => {
it('owns every action and validates discriminated parameters', () => {
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
expect(validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 9,
windowSeconds: 3
})).toMatchObject({ ok: true });
expect(validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 0,
windowSeconds: -1
})).toEqual({
ok: false,
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
});
expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false });
expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({
ok: false,
errors: ['Distance must be greater than 0.']
});
});
it('owns every action and validates discriminated parameters', () => {
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
expect(
validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 9,
windowSeconds: 3,
}),
).toMatchObject({ ok: true });
expect(
validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 0,
windowSeconds: -1,
}),
).toEqual({
ok: false,
errors: [
'Hits required must be a positive integer.',
'Window seconds must be greater than 0.',
],
});
expect(
validateTriggerConfiguration('while_firing', { unexpected: true }),
).toMatchObject({ ok: false });
expect(
validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }),
).toEqual({
ok: false,
errors: ['Distance must be greater than 0.'],
});
});
it('generates user-facing summaries from typed values', () => {
expect(summarizeTrigger({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 4, windowSeconds: 2.5 }
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
expect(summarizeTrigger({
action: 'continuous_firing_duration',
params: { seconds: 1 }
})).toBe('Fire continuously for 1 second.');
expect(summarizeTrigger({
action: 'target_hit_within_distance',
params: { meters: 20 }
})).toBe('Hit a target within 20 meters.');
});
it('generates user-facing summaries from typed values', () => {
expect(
summarizeTrigger({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 4, windowSeconds: 2.5 },
}),
).toBe('Hit 4 distinct enemies within 2.5 seconds.');
expect(
summarizeTrigger({
action: 'continuous_firing_duration',
params: { seconds: 1 },
}),
).toBe('Fire continuously for 1 second.');
expect(
summarizeTrigger({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
).toBe('Hit a target within 20 meters.');
});
it('validates unique health bands and their outcome relationships', () => {
const result = validateConditionalProgram(program({
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' }
]
}
}, {
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low' },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
]
}));
expect(result).toMatchObject({ ok: true });
});
it('validates unique health bands and their outcome relationships', () => {
const result = validateConditionalProgram(
program(
{
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' },
],
},
},
{
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low' },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
],
},
),
);
expect(result).toMatchObject({ ok: true });
});
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
expect(validateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
outcomes: [{
id: 'damage',
scope: 'target',
application: 'direct_damage',
basis: 'flat',
value: -10
}]
}))).toEqual({
ok: false,
errors: ['Instant outcome "damage" cannot use a negative value.']
});
expect(validateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
outcomes: [{ ...modifier, value: -10 }]
}))).toMatchObject({ ok: true });
});
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
expect(
validateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
outcomes: [
{
id: 'damage',
scope: 'target',
application: 'direct_damage',
basis: 'flat',
value: -10,
},
],
},
),
),
).toEqual({
ok: false,
errors: ['Instant outcome "damage" cannot use a negative value.'],
});
expect(
validateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
outcomes: [{ ...modifier, value: -10 }],
},
),
),
).toMatchObject({ ok: true });
});
});
describe('evaluateConditionalProgram', () => {
it('counts repeated hits against the same target inside the window', () => {
const result = evaluateConditionalProgram(program({
action: 'same_target_hits_within_window',
params: { hitsRequired: 3, windowSeconds: 1 }
}), [
shot(0, 'alpha'),
shot(0.2, 'bravo'),
shot(0.4, 'alpha'),
shot(0.8, 'alpha')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result).toMatchObject({
status: 'OK',
activationCount: 1,
maxStacksReached: 1
});
expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 });
});
it('counts repeated hits against the same target inside the window', () => {
const result = evaluateConditionalProgram(
program({
action: 'same_target_hits_within_window',
params: { hitsRequired: 3, windowSeconds: 1 },
}),
[
shot(0, 'alpha'),
shot(0.2, 'bravo'),
shot(0.4, 'alpha'),
shot(0.8, 'alpha'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result).toMatchObject({
status: 'OK',
activationCount: 1,
maxStacksReached: 1,
});
expect(result.activations[0]).toMatchObject({
at: 0.8,
stacksBefore: 0,
stacksAfter: 1,
});
});
it('counts each distinct enemy only once per window', () => {
const result = evaluateConditionalProgram(program({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 3, windowSeconds: 2 }
}), [
shot(0, 'alpha'),
shot(0.2, 'alpha'),
shot(0.4, 'bravo'),
shot(0.6, 'charlie')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('counts each distinct enemy only once per window', () => {
const result = evaluateConditionalProgram(
program({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 3, windowSeconds: 2 },
}),
[
shot(0, 'alpha'),
shot(0.2, 'alpha'),
shot(0.4, 'bravo'),
shot(0.6, 'charlie'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('resets consecutive hits after a miss or target change', () => {
const result = evaluateConditionalProgram(program({
action: 'consecutive_same_target_hits',
params: { hitsRequired: 3 }
}), [
shot(0, 'alpha'),
shot(0.1, 'alpha'),
shot(0.2, null, false),
shot(0.3, 'alpha'),
shot(0.4, 'bravo'),
shot(0.5, 'bravo'),
shot(0.6, 'bravo')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('resets consecutive hits after a miss or target change', () => {
const result = evaluateConditionalProgram(
program({
action: 'consecutive_same_target_hits',
params: { hitsRequired: 3 },
}),
[
shot(0, 'alpha'),
shot(0.1, 'alpha'),
shot(0.2, null, false),
shot(0.3, 'alpha'),
shot(0.4, 'bravo'),
shot(0.5, 'bravo'),
shot(0.6, 'bravo'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('starts cooldown on activation and applies the activation after its triggering event', () => {
const result = evaluateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
lifecycle: {
cooldownSeconds: 3,
durationSeconds: 5,
maxStacks: 3,
stacksPerActivation: 1
}
}), [
shot(1, 'alpha', true, true),
shot(2, 'alpha', true, true),
shot(4, 'alpha', true, true)
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations).toHaveLength(2);
expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]);
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 9,
cooldownUntil: 7
});
});
it('starts cooldown on activation and applies the activation after its triggering event', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
lifecycle: {
cooldownSeconds: 3,
durationSeconds: 5,
maxStacks: 3,
stacksPerActivation: 1,
},
},
),
[
shot(1, 'alpha', true, true),
shot(2, 'alpha', true, true),
shot(4, 'alpha', true, true),
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.activations).toHaveLength(2);
expect(result.activations.map((activation) => activation.at)).toEqual([
1, 4,
]);
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 9,
cooldownUntil: 7,
});
});
it('treats an explicit non-headshot hit as a body shot', () => {
const result = evaluateConditionalProgram(program({
action: 'body_shot',
params: {}
}), [
shot(1, 'alpha', true, false),
shot(2, 'alpha', true, true),
shot(3, 'alpha', false, false)
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
});
it('treats an explicit non-headshot hit as a body shot', () => {
const result = evaluateConditionalProgram(
program({
action: 'body_shot',
params: {},
}),
[
shot(1, 'alpha', true, false),
shot(2, 'alpha', true, true),
shot(3, 'alpha', false, false),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
});
it('refreshes one shared duration and clears every stack on expiry', () => {
const result = evaluateConditionalProgram(program({
action: 'damage_taken',
params: {}
}, {
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 3,
maxStacks: 3,
stacksPerActivation: 1
}
}), [
{ kind: 'damage_taken', at: 0 },
{ kind: 'damage_taken', at: 1 },
{ kind: 'damage_taken', at: 5 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([
{ stacksBefore: 0, stacksAfter: 1 },
{ stacksBefore: 1, stacksAfter: 2 },
{ stacksBefore: 0, stacksAfter: 1 }
]);
expect(result.intervals).toEqual([
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null }
]);
});
it('refreshes one shared duration and clears every stack on expiry', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'damage_taken',
params: {},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 3,
maxStacks: 3,
stacksPerActivation: 1,
},
},
),
[
{ kind: 'damage_taken', at: 0 },
{ kind: 'damage_taken', at: 1 },
{ kind: 'damage_taken', at: 5 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(
result.activations.map(({ stacksBefore, stacksAfter }) => ({
stacksBefore,
stacksAfter,
})),
).toEqual([
{ stacksBefore: 0, stacksAfter: 1 },
{ stacksBefore: 1, stacksAfter: 2 },
{ stacksBefore: 0, stacksAfter: 1 },
]);
expect(result.intervals).toEqual([
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null },
]);
});
it('resets firing progress on reload without removing an active buff', () => {
const result = evaluateConditionalProgram(program({
action: 'continuous_firing_duration',
params: { seconds: 2 }
}, {
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 4,
stacksPerActivation: 1
}
}), [
{ kind: 'firing_started', at: 0 },
shot(0.5, 'alpha'),
{ kind: 'reload_started', at: 3 },
{ kind: 'reload_completed', at: 4 },
{ kind: 'firing_started', at: 4 },
{ kind: 'firing_stopped', at: 7 }
], { mode: 'encounter', encounterSeconds: 12 });
expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]);
expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 });
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
});
it('resets firing progress on reload without removing an active buff', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'continuous_firing_duration',
params: { seconds: 2 },
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 4,
stacksPerActivation: 1,
},
},
),
[
{ kind: 'firing_started', at: 0 },
shot(0.5, 'alpha'),
{ kind: 'reload_started', at: 3 },
{ kind: 'reload_completed', at: 4 },
{ kind: 'firing_started', at: 4 },
{ kind: 'firing_stopped', at: 7 },
],
{ mode: 'encounter', encounterSeconds: 12 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([
2, 6,
]);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 16,
});
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
});
it('activates continuous firing only once in each uninterrupted segment', () => {
const result = evaluateConditionalProgram(program({
action: 'continuous_firing_duration',
params: { seconds: 2 }
}), [
{ kind: 'firing_started', at: 0 },
{ kind: 'firing_stopped', at: 7 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
});
it('activates continuous firing only once in each uninterrupted segment', () => {
const result = evaluateConditionalProgram(
program({
action: 'continuous_firing_duration',
params: { seconds: 2 },
}),
[
{ kind: 'firing_started', at: 0 },
{ kind: 'firing_stopped', at: 7 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
});
it('uses only the strongest matching health band', () => {
const result = evaluateConditionalProgram(program({
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' }
]
}
}, {
lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 },
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
]
}), [], {
mode: 'encounter',
encounterSeconds: 20,
healthPercent: 15
});
expect(result.selectedOutcomeKey).toBe('critical');
expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]);
});
it('uses only the strongest matching health band', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' },
],
},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 0,
maxStacks: 1,
stacksPerActivation: 1,
},
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
],
},
),
[],
{
mode: 'encounter',
encounterSeconds: 20,
healthPercent: 15,
},
);
expect(result.selectedOutcomeKey).toBe('critical');
expect(result.intervals).toEqual([
{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' },
]);
});
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
const result = evaluateConditionalProgram(program({
action: 'weapon_kill',
params: {}
}, {
lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 },
outcomes: [{
id: 'blast',
scope: 'target',
application: 'direct_damage',
basis: 'weapon_base_percent',
value: 25
}]
}), [], {
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 6
});
expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]);
expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]);
});
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'weapon_kill',
params: {},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 20,
maxStacks: 3,
stacksPerActivation: 1,
},
outcomes: [
{
id: 'blast',
scope: 'target',
application: 'direct_damage',
basis: 'weapon_base_percent',
value: 25,
},
],
},
),
[],
{
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 6,
},
);
expect(result.activations.map((activation) => activation.at)).toEqual([
10, 20, 30,
]);
expect(result.outcomeEvents.map((event) => event.value)).toEqual([
25, 50, 75,
]);
});
it('uses real trigger events when the whole-program activation override is zero', () => {
const result = evaluateConditionalProgram(program({
action: 'weapon_kill',
params: {}
}), [
{ kind: 'enemy_killed', at: 4 }
], {
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 0
});
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
});
it('uses real trigger events when the whole-program activation override is zero', () => {
const result = evaluateConditionalProgram(
program({
action: 'weapon_kill',
params: {},
}),
[{ kind: 'enemy_killed', at: 4 }],
{
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 0,
},
);
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
});
it('models headshot kills from enemy-killed timeline events', () => {
const result = evaluateConditionalProgram(program({
action: 'headshot_kill',
params: {}
}), [
{ kind: 'enemy_killed', at: 2, headshot: false },
{ kind: 'enemy_killed', at: 7, headshot: true }
], { mode: 'encounter', encounterSeconds: 15 });
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
});
it('models headshot kills from enemy-killed timeline events', () => {
const result = evaluateConditionalProgram(
program({
action: 'headshot_kill',
params: {},
}),
[
{ kind: 'enemy_killed', at: 2, headshot: false },
{ kind: 'enemy_killed', at: 7, headshot: true },
],
{ mode: 'encounter', encounterSeconds: 15 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
});
it('activates on successful target hits while the target is within the configured distance', () => {
const inRange = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [
shot(1, 'alpha', false),
shot(2, null),
shot(3, 'alpha'),
shot(4, 'bravo')
], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20
});
expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]);
it('activates on successful target hits while the target is within the configured distance', () => {
const inRange = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[
shot(1, 'alpha', false),
shot(2, null),
shot(3, 'alpha'),
shot(4, 'bravo'),
],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20,
},
);
expect(inRange.activations.map((activation) => activation.at)).toEqual([
3, 4,
]);
const outOfRange = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [shot(3, 'alpha')], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 21
});
expect(outOfRange.activationCount).toBe(0);
});
const outOfRange = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[shot(3, 'alpha')],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 21,
},
);
expect(outOfRange.activationCount).toBe(0);
});
it('requires a target-distance assumption for distance-limited hit events', () => {
const result = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [shot(3, 'alpha')], {
mode: 'encounter',
encounterSeconds: 10
});
expect(result.activationCount).toBe(0);
});
it('requires a target-distance assumption for distance-limited hit events', () => {
const result = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[shot(3, 'alpha')],
{
mode: 'encounter',
encounterSeconds: 10,
},
);
expect(result.activationCount).toBe(0);
});
it('keeps target proximity as a persistent condition rather than a hit event', () => {
const result = evaluateConditionalProgram(program({
action: 'within_distance',
params: { meters: 20 }
}), [], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20
});
expect(result.activationCount).toBe(1);
expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]);
});
it('keeps target proximity as a persistent condition rather than a hit event', () => {
const result = evaluateConditionalProgram(
program({
action: 'within_distance',
params: { meters: 20 },
}),
[],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20,
},
);
expect(result.activationCount).toBe(1);
expect(result.intervals).toEqual([
{ start: 0, end: 10, stacks: 10, outcomeKey: null },
]);
});
it('keeps while-firing effects active only inside firing segments', () => {
const result = evaluateConditionalProgram(program({
action: 'while_firing',
params: {}
}), [
{ kind: 'firing_started', at: 1 },
{ kind: 'firing_stopped', at: 3 },
{ kind: 'firing_started', at: 5 },
{ kind: 'reload_started', at: 8 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.intervals).toEqual([
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
{ start: 5, end: 8, stacks: 10, outcomeKey: null }
]);
expect(result.uptimePercent).toBe(50);
});
it('keeps while-firing effects active only inside firing segments', () => {
const result = evaluateConditionalProgram(
program({
action: 'while_firing',
params: {},
}),
[
{ kind: 'firing_started', at: 1 },
{ kind: 'firing_stopped', at: 3 },
{ kind: 'firing_started', at: 5 },
{ kind: 'reload_started', at: 8 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.intervals).toEqual([
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
{ start: 5, end: 8, stacks: 10, outcomeKey: null },
]);
expect(result.uptimePercent).toBe(50);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More