10 Commits
Author SHA1 Message Date
JB 5a67b252aa feat: add prelaunch site controls and refresh branding
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 19s
Validate and deploy production / deploy (push) Successful in 1m0s
Validate and deploy staging / deploy (push) Successful in 36s
2026-07-27 02:24:13 -04:00
JB 7b3aff30d8 checkpoint ISAC registries and system monitoring 2026-07-27 00:31:16 -04:00
JB 8b80e55e6c refactor: remove retired data format support 2026-07-26 21:01:36 -04:00
JB 6ea45c05fa feat: refine ISAC media and notification workflows 2026-07-26 20:36:30 -04:00
JB 338ea8761d feat: model specialization combat effects 2026-07-26 19:04:26 -04:00
JB b1656c430f feat(isac): link skill chips to abilities 2026-07-26 17:04:47 -04:00
JB 6722665f5c feat(isac): add specialization data management 2026-07-26 16:20:04 -04:00
JB ed31748102 chore: refresh Impeccable design artifacts 2026-07-26 13:59:00 -04:00
JB 63b04d26a9 feat: add managed ISAC image library 2026-07-26 13:46:44 -04:00
JB e8ed35e35f feat: refine database discovery and ISAC interactions 2026-07-26 13:24:30 -04:00
148 changed files with 13067 additions and 995 deletions
+13 -12
View File
@@ -5,21 +5,22 @@
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
# .env.development
TDR_DATABASE_PATH=encrypted:BNTRek+Cz9NI9Qasv0a+BWUNs5u5qTESE+1U7fybMS3NJLQkY8WR/eQ0v8/ZoiiHLSM/EGum5NRNC/iRSYIjkjFTvfC4xFnUXALvy3zM2Iw4AezGWdmOZ6RI5csNtGQKQ0idOicuA1YN3FpcDdOSSZXTOyVniwFduWcAZTuBPTo=
DISCORD_CLIENT_ID=encrypted:BDuk9O2rhgVwi6/NqnG1+n2Jeb/A6ciQwNAdY3Ih7R08DhTF4g2d9ZKpg+OzmIL7BeZF3ikkedlfljxn8xbW8f9YGrrfaLLdTDVClyT+scyfcKeaK6uunQVvipG5UwhotEd3VS8T9IcQyNDVq9y9SEgIjyo=
DISCORD_CLIENT_SECRET=encrypted:BE3+3NfbuQpjbpc8Pyl0e5YEW478dOKdi5cWuwHjJ5L2z/X+1hJ+B6ag4oXwPBt20t12qWtv5IYPJGMb+Uzk6hZLazqkHxl+DUozeNMAOOrNHESPiTwH7fpI0vf4oVTWH+j4iYMRUlmLkjzOICTmAL+ljOJgi5dTqHx05unXGqxZ
DISCORD_REDIRECT_URI=encrypted:BEWUNH2YlygQ23TBiXj9lgJ7hijU13YJhiBYVM1VEHHM3s8ryqWJ+hz2bJ0EyGHg3XU9UBPDhmgLkafULYzY/KgOLmSMq6TkrYq1e7Qec+K70ZyqoNp7L1s9C0EK//dpkE1iMvd92bhLPOeS8sOhFN4dyYbK2vPGRdi7AKxFwiU/jq7pbwkHeU5DQXU=
TDR_DATABASE_PATH=encrypted:BDdEUO0HX3ZgfPHXCtNAjISsKdb3IZlOGDZE2vSmAGm4Y2+UgLAoOX8Ij+CL/GJUD0IVU0HUsTyEx6CrXutXEd1uWz7dxqgtra59jmBKasN6ucrgaY/8ktRq/uoTm/t99xyuEWRXJdZMFSk7YkbkQuHgvoU5K3nqI2ml0mEFeL4=
DISCORD_CLIENT_ID=encrypted:BA9XZlnAHz/glIBFqEbROTvLmgW1ZzONZ+ZPVHyw6hQB7U6YxnYfdmQ+HJnLkgkJyib6PFc+AijW11xgd5B8Z3BvIsXjzjs29fFs4yYnEVbxvoBD2KB2XJHtqabdVIYq9dFjfG/zY1hb3CIi8FSgMVFxxBw=
DISCORD_CLIENT_SECRET=encrypted:BHhFIIU5v7Ljz9LtAR3z6Y6qAswOtYDKtcvXW4EXAs85OiZ2sjfQ1VNoBtkg2wtevEmTvxLoyq1EEBcSIAmAIUeBaJPZa60FNK0nU7y/GaUvqV+Q8G3V7AkRmoptiOZ4jduiPzlzs3+jNvJmrf6KEnYWKx4ytEQpADO75BgDhoCS
DISCORD_REDIRECT_URI=encrypted:BL1YDjAhLKcVVwiBUzjkHt9wLuM6bZJXl1RnZO0DWanGa39LjiQ3ybJFN0I2jNVgcVjqk3nSiCYDGOQt49UeF08aMdJkOa67SVR9yDtESJtMKlPJxL/HaLf8eAex4DlO/R7NlwDRUua5pRBcu0TH6UeC7FphlYNGRFPwfcuso32Hg+Fl5f1CKU4tUTQ=
# Passwordless authentication
APP_ORIGIN=encrypted:BE7igk/cyE/Wv72Rc8A0O815n/uDyyaKdLwdonSjrc7rHWiwBifF8S6EQ5tUkH8i0eQsxLGxcAJTW0H9gfGw0Nftxl+1Mr2slGLZJxxiJ4Upp1CU3pLwIgcTg82ew8ewOvfYnT6mVLPMHO/02vomjOxYobLKdQ==
PASSKEY_RP_ID=encrypted:BNMQdCdZR9e47vjPfme83ujNlJqsmJgGeri8r+uuJ1MwHeWXuv8FKi+XHKKX8GaXvEdqD0mLSFyYvUMgrzJb/ZwEo/B77opQ67AXFtERz4qUKDc6XdOvFNRUvlPjPaO/eoyqr64WIEjG3w==
APP_ORIGIN=encrypted:BNe3AfHVmq+afhm6TwmoT+SDn+oUfz0S0NgP7rjh+o0mHizYiq0/pInyIVarq0Z7WTwlhJIW0SCE72DMoQvO2mIEdnO4jOxXOZywTpcMHpWZ8fBgVNC5wlH+KYnBmH5MWg0fjrPALXNpTAyYGoIiB1WiuiDrpA==
PASSKEY_RP_ID=encrypted:BMDp5uRV/HQ6BaqEOODDXYrrukSRNMVrUzr56yoMUkJwIogCT+SmedS/pXJGvi7er8PvJGSfC1SoX8G+YiUFua/dcSERXFVGRg+p8LywPZDN/YuEfv5p6GuRgQmd/rcPvP5TUqhC0SEPIw==
# 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:BGk17DA/BpNINXIl4d7LKTLi3IfAjvyk1OhP5yPnk6ZjE3FHSJl3ed6tQh5oPoUt8zovm+hyV2rD/Pae+axnWdxKXtUohp5HkqNqC1T4SEeVWpUsx5m/xXt8hxcwEQ/1HfzTcdx41wo/hR2ereZMFaX7sA==
SMTP_PORT=encrypted:BBzjlvppArJeJNKCeNyuf4+bX4ohICRycdb/BbZTryN114iZ1d6wyQr6Gre2Eg9fhTQqdIiDjjqpb9UWbjavutlHFZ0UarMUWaOBRfVd9+sqmXI8/vmA6Scp8z4MxteqB5+6jQ==
SMTP_SECURE=encrypted:BBMBvqEuiHmFAy+C56yr/g1kPQsVHu+xO5eVtHis4vdAHHsGVO/blfVMHDw0PAiHPFITHDrp7GjNnvQSq0AKgLK+Mn2/Iv7XC9oQedoplQFOY4kNuM77qWJur/H93DgtMckkfNB+
SMTP_USER=encrypted:BHmiGy4eBiIOP5PLYBsqomWDCc7DSFvmfXPyPtD6/Cj0KLQAWaVVQowQbsc0XuvuOhesEAcrMBtAD0+rsAmAhoHvdudqc7jPXk0IAFeeasAp8CWvPzd2Afb6tI/Z2xv5BxVsR6l4p1uh+SlK0lfskBGSdQ==
SMTP_PASSWORD=encrypted:BJ8mNgq9j9mmsptaEP8L47MbuB/l9jmkMGXco5ybw91a/vZFyZ5xLWTUxdVYYlHiBIAhjs1eEsx7dQgc4+5wmGFyNfDILod5Turp/OqDD8qV2c7wsKGzOrV6TCGELscoxiHg28YCZCriZnIs9Uw9yRE=
SMTP_FROM=encrypted:BGOoIMcTZ8Wknq81asUOHBtHsqKmUeVq8SDdvp+F5lWKwWxSos8cE/inhJk8gfVEp8cf6m8xVZQt+qUh0lscKA5LPpyixnFe1AyHRAlfqc+r2200492HxHrwKZfEzZ9RFMlN7omgC8ADAZ6eo5mvT/udqHWJpIQLJNSV5GFgjw==
SMTP_HOST=encrypted:BPyC/VDuZ5nvNssrmooa1yMlQ2DJUBTViB+84o+RdmzKaIYtHSw5ZkIAA8uNjDyCGSKtmf/6TCDD2CCz6IOCcLgkT3e6ycYVp1Sa7LCqeLNK3azR1QIRzS9zwEq5txtuzdDK5IvzUNqNdduR0PlZZrJNvQ==
SMTP_PORT=encrypted:BGMe8boYWFP3ZnDGSQYOKBfDne+0ylzmONtpqslJsv6FhrhB7v7Tho7fSdOMvC7/F987J36eKP/dxyje8uWCAA8D/305HiIm4I78nPd9rJ3x1WVLJGO22TZfUJDIdbBF4WR04w==
SMTP_SECURE=encrypted:BJA9tMvTfopV/UTxmrfRt3Xaf73VokM3TPTg4X+NEPjk7C8wrifPcRbFsA0bWz3XWAzHoGTPCICQm2iaPh/KkVlpY0KE2ZUME9u1RirhjngByaK4m+bFfSHg2J9NRffGDTtevMvE
SMTP_USER=encrypted:BKeUeUDV0hfc79vQuUrmMPJbIs2yFzfupaIXjSBlgJzuQtTqX6cQPIUV+4xIoO8pycOrf5esF7YN7JZXdgZFhULPyTW/ufBkMmDffkrH2l5aOtdV6EArXdHzo/V76UNr7/2Kzx5aO+3CcxvKwOHH9/WReQ==
SMTP_PASSWORD=encrypted:BFmv9RUty2dlzHo1wm8bbqDECJG/npi8hciwAu7jzssSCU28hFXPiPeAdTw6thCAlLAm2ECR2z3Oopn1I77wJZDAh1N5JRZ2eybgE205VDLq4NnbqwH1tljDwlyDY9z+6uBGtoANnnW/DR8GPZwAoWw=
SMTP_FROM=encrypted:BMQEkKEg7FbSqbF5LZwUYWMbcFLBXuVoJ5zpGGnCkSyrV7fuA4wXZ7lBjQz+b9iMNmhDxO9n7lsVRARkXQVHWLx3lUTdnfrh3dzphfTqG6TVBaWX+JIkrN+V4MRYyopLPqh4ZfrirZPz1qq6q/1HZcSmSqC6NQF6dBJQBHzCJQ==
REMOVE_BG_API_KEY=encrypted:BLhGrWHuRJsZ0sbBiGpZpoYecsJpw4l+jVnocSJWl9kszTgewHl29FPZXS2FEJUg0NRGVpuNvT6+l9k69pO8vO6UGl4tngNu/bzcZaDGMdTfAlS4Q9KvLQIaYs7DzlE7tlrfaFom9XGCSWoLEDnAhwtglZaz4xMd8A==
+4
View File
@@ -40,3 +40,7 @@ SMTP_FROM=SHD.build <no-reply@shd.build>
# Long random bearer token used only by the internal container scheduler.
SCHEDULER_TOKEN=
REMOVE_BG_API_KEY=
REMOVE_BG_LOW_CREDIT_THRESHOLD=5
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
REMOVE_BG_CREDIT_CHECK_HOURS=6
+13 -12
View File
@@ -5,21 +5,22 @@
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
# .env.production
TDR_DATABASE_PATH=encrypted:BE4lCm2/hLx3wEPCPvmIWHEPHS2oVLZHcWS6e4FmRwwkSR7GxaifHgccy32IInzWXiUp8P8OnZS6MYJn5X04wKtWV0y4FfbiCGs0e8YpM1Fo513hd1LmB6uDZo5pkacJV07KpG8NV01RveoHhe/ANN/UVwStFc7eZj85G4cu6A==
DISCORD_CLIENT_ID=encrypted:BK2ZUIggBGYBCU3zEaSOmo2mxqiWnOKtdKxK7FWTw56bG3xFoXtmSZ/vyz+OF/pLd6ciS/WO1nAxC+FND4mLqdZfm99MrWacQj79///zB+vsouDdwg3zqS76dxQ1zES4lPTzk+fhRqHe/EsqQK4sweax4fc=
DISCORD_CLIENT_SECRET=encrypted:BAwObVwDUb5HMWun2WUYpm3zrqFQHegE1KQnfq5I9tglZQXl6/XxkwHtNQFmoU0WgpsJERT1N+yO+0CtBzhdpufIHyXqjCj8uYD70wJvXY2BqEkth0JgKcMi7lO7gSgtybnzUGTDS0xLqjpBgw9lkl0rCun7axH/gkrd/rUEXLdF
DISCORD_REDIRECT_URI=encrypted:BGgy7YS2iU62+mSDlUDNwvZhE39Pr2o2kpnFJCg/16MaQK/VoZVyICSuLBuFE0snAUb8KIvcnklusqao8TU1izHeTS+rvBMLvRVzcyHDTZUynrmn4UCzG5w4phiTVi2OhaZvw8hQSkcvoV57mVtBo7LNEAuVEhuv5WAeOHOX9oaV4qYfKVviRQ==
TDR_DATABASE_PATH=encrypted:BHIvIe9XAoIG5lp+e2weR7nRX3Q6s38O9IqDVFjk9IikerZvrwVBSxDl0g85lrcP36RG69bCjWHxpZxTpbTHdimh+sSbM+b743VM3w++0Z22VSf8vyVM11Mp6KfLG2GwId3ExX2z+ReavxqdczjirHBAvzbGWliDPfP3zey1vQ==
DISCORD_CLIENT_ID=encrypted:BOJFAQzTZp0HWmZzMMHrgHwq6iqu8k7HjLQ/UDD3Z7vMsR8N7denoVb8MhTOw/0Qj9bvJ9AENBiXTBlwY9F0z03xH+4+E6Hh6z0DzDF+GU6crTtNPjOdMIFgWM2NLRZM5pl/z7giHZmk0uBVDfP3H6S1vXc=
DISCORD_CLIENT_SECRET=encrypted:BCa9bbGfHMNn60xvnpiHx93M0t33eqoMR4c5K2J2esrUSnfh4nY/vwdY5JV1vH/G1DH5VG2RJ+v2i0/T7GPMJHD0ErvrS4pB6evBS4BvBFAECwnx2lnO0zC9X8YzmRGrIFjW2zZFp1kINMqCc8t9L3GoHV2jFuLOOxJ0jRWnVVAd
DISCORD_REDIRECT_URI=encrypted:BG+znHavQa2TTpCZh9KCLPAQr3vpqyraygRgfxBXMNym1/9UepTrtHuWGrdymdUHS9xKkpvutuWZW3Nlb9X+359izBfJEW5pXOcUnXNE5FOukFgYnmJVcGU1qglwufmIeJc/qqa5AGvyzzO1CkxkblO91+g7fk/DWWhrsSHlqGZkx5ULyZziiw==
# Passwordless authentication
APP_ORIGIN=encrypted:BDJyyjEXFeEjdJiHOGRlxpiWkbzFJBZnM6w1VAb8m4YRW+c0Jvg4kl1XzaRtf63TLjfNWVxxwhCBwJ9s5qeP2+O7FvbgKKxoEXeFyP+gBUDsXss8volhd7toPaF9sjdAtpXY5L0M7SVYOct+FQCHAoyX
PASSKEY_RP_ID=encrypted:BKvPDWDsTEwhAtFAsKENg85D4Klo+Ipp6KnZ0EZ1Nlyi6ikuswRqDFrqYtlleBhzwAHhl7/uD3P6e2cyaNvffokcwQQQb8CXhCVRyGFgy1htvpFXhMmdesMfuqoVZ87wb5ngDlHKKV3E2A==
APP_ORIGIN=encrypted:BFwQW4kaK11NK/UnBLjgwATEcLnAjvR9fFWCmxphBiJHNSYGTBBmpAvhZGvVoA4p/Q9fLP3el+BWgsg2Y3uFajL5oLSMx04UagGm/kX2K1PKztiDyeS1U8qSVQHeVUBtRXpnH7ugbfbLBg9IgXKHUS1L
PASSKEY_RP_ID=encrypted:BPR2a1uPeIaD9KRQAp/6n5h0dE9cDlGMqZtx/asRbey7eeTs2N9ZM5HYvXG2pnIyxO30aD4NrNh7IvJrb52qCXObDdBB0LXn7rv1VYdCQOVxgvpcV+mOb7bECORvm6TGxBTYnf8QwRH+Tw==
# 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:BNKsoY8sz2Kf1ZaRtE1pU7JQM36Qj+KADK23R+pwVYV3RmeO6OP6AtGVGjDoHoPAqCsoSLPLM0INyX9XFdEJP+DtN0EVVoHgXt4+fAd1kXDBZTwZ+NEgFP6E6h8V5gZdMhar6hrnWecGfrFzs2c/1+e5Kg==
SMTP_PORT=encrypted:BDd5baDM0FWRaEDV/5s94/J8a2Ll9ZxLhBy5NVytdKXPbSq40E9UPKHPNS9vqet69Qceq7rzXgLmcdiqkqp0HdlocJUWzA5YIYcY5Er31o7nau7SKAk0KfnzHA6VVsvRDWxo/w==
SMTP_SECURE=encrypted:BPH6vl2VFiVF0m1sE5Ybt8rrM2TjkgEO5pKnu+5P++fiLkTnEr3bp5tAPbhqWAXpmgmXt2SB2KK/j9hH4Ih0/1cGNvSrqee5FNpwHKncrxrVRzdwSuIgYViziwCrReKXxR+uhzVD
SMTP_USER=encrypted:BKWljaiPijrWfVy5QHxWC83SCzs/MUlzFnI0i+CG19ZSfJ3dUqIdnACqjkpuYI5T/tO1AXB2kOptDc6M7foGdUZ1uDHzTWVOUl5yt0kx6rlAPExuRD9PmOdWvH/CzDQvn6MblJumUtZ5XgNTXG6RgZBqGg==
SMTP_PASSWORD=encrypted:BO10G2D7osxIP5jZP9SQbMFMvcmKJ6Pd4jFzWa2oHnz7RjY5pbRX7r+jFddm0lZqgj6jVJnJiFPMxKSpuSKJUoHKgXL36z5LcmwUuILysyKJAWj5HoeDOGzsgASz0pezESsDoWeH1+0iWPbw6A0AmUA=
SMTP_FROM=encrypted:BJmIwi0QgCDhR6cxkHP5EHsWZznB8ixNaHYxFfWMoVGTgHeTwmrXRDPsXBPXBXUMJkpr6yRAOpeADQjJ4TyqhQVw7R1ghk8nzuESH3/oVxPtpBFYaNkahDHMna+x4ZjsRQFwaisFcLm3RPhPQ8WRfR4VtxUjAnSRyfiXNVflsg==
SMTP_HOST=encrypted:BIjGLiRygyi55OmuLVK8LH0rgUurPZSrM7dom9IG7uE0Cpa9jVoPUek3p3MIyUUyN8SwdDRnr0+WGQsdR47YVnUcRSVVIx1K1HtTre+O6S60QLbPrk7Yv4ET4nl2kmaovPHalp7TpTcZpG1ZcaPy0LQEdQ==
SMTP_PORT=encrypted:BBwsMpX2peGY35Bxv6hFEYlRWouITB7H7HU7/oyA65inKbY0W/JmJIeAng7faJnw6YbUWH9aT8+ztwaBTdtO6VRFclbZcQiJv5MuRHIgHPeydHrH0+Wl5yXaXAiT7iY5itRqug==
SMTP_SECURE=encrypted:BKVehx9fXwB3B0OFnFnIFn5vdMa4Jx2zFtxpkHUNpnxHfJ9zHbI+9RqikZWzwqYV/vKS5KCWsLIIUH2Y+xdodhWeIlvjbYtHB2nkigc7l3xkXJ06iVL/DGWSBytkqZQAz187pW6M
SMTP_USER=encrypted:BLsCJXx0nJBw15JpIqZM+I+EmcfA3yOCr9cAr7msQjsWEYOlRYJQFHQ5mJqwak250QiZl9js+H4NA5idr3HYif1/JkBjcmJyRLc2Xn0ma8IeiwKZqKA0uRM/P0YjQ1+Ez8yO4SYjMEdAoMcAgQldi5K94w==
SMTP_PASSWORD=encrypted:BOXbGNPqBrJpKzSub0j1rDztZOj83JlJSt6blUeqkC7axIGGAjzGbiZeiQ2lrxTADnr0y10LQzz2i4Rw5pj3FsF7sSLko3sHnz7iqK2Foy5ZeSwtX0AgeD3CoV3DyGdl1awl4RhbS45j8JoThzekoqg=
SMTP_FROM=encrypted:BLhAJc0wdRAFRfIHHoq7gvksB2Aus0mV6vVq9BRqznvqHC3OKHHVlCknSD9i95NAkJf4rpaCHahCCu4QIN6Iq94s9+Bj3lnQJuWPt5N67ghegeNeRcHSkq+OomadpJYv6PDDUmJs+Yoc6QkVjWjPVMi4suYZE/V/k17vmGzDZg==
REMOVE_BG_API_KEY=encrypted:BBufDrwuHcLE+i64HX8G0uH2L/MPZF5ZYbLRXNC+EVClzpMnrYpPXpsBJCRVmWJ2f47pH3cEY5dIaTM7JrDhomS/znF54zMwRyszSnlzMcppEEkyN2FicJ/NWR9woOQOGTbtqKdePCMqS3SU+Zq/fNKgvK1Y+COHDQ==
+17 -16
View File
@@ -6,23 +6,24 @@ DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b
# .env.staging
# Staging is deployed at https://stage.shd.build.
TDR_DATABASE_PATH=encrypted:BEfWarm0yphwbJYuA9UCHZDY4pMcFw1HLHras694Tj2njTVgIuYcJZ2pJEkj8rjWbtFrCGh052GTqf0pX6KmWXTeu0WWgEanSJiRXZ8CYRt8/rif9gk/bqHOq1CUZfG01+eCIqkN1pOEGVAJEsqjkn/EbiWXzcqBtXisBOYdl40=
TDR_AVATAR_STORAGE_PATH=encrypted:BMoxUtKT0LXRmZMrj776svimltQlS7TEhv3ganVjJEbTS+4WlGDHzqxQ91+6nUexyXxeBWW7n81UeRTknZqzlENY3dFsaVtYUATv9meKfp806Ab4BVmFdNCVba+GPEMqJ57Sw2fG5ncLrn/AHniLJ0UU
TDR_GAME_ICON_STORAGE_PATH=encrypted:BAv4kKj8sR1An7Ym9+hLCIu4xSQdC4fc5dyRy+WaY00kmKLQ7Pj4pO9qc4Vzx4nNYp8124WevhHQeB6CzCoK94owCsmX/HHoEhajcoEGFSOaferE61bJfufK1pbzb7SRsQbZQjCh3yqFlTooYs1x/QHBZ5WW
TDR_DATABASE_PATH=encrypted:BLYcX+Rghxqp069PQEWL+9tRPGUfs/ae0D5ga7bNC4Y0llIwGFcd1pq6JqelF/hSkG+3bdy2anahWeU7bY8t6EjVTFobQ0LlQvQbpBwRKwHgDGEwESovjhajDuPv3b62hfuh9fqVdODR3Csu8ndmlq8SiObT3JgyyzljIZqDwMQ=
TDR_AVATAR_STORAGE_PATH=encrypted:BEL2fezcXf+tr0K63V+5EHWxNbjCNH3puOv+TiDA3fmvmQyTBtnHkMwJQK/utubqxjtV3oDRMW2Pzyzrui1aD444K+qqe+Ok4xtTLqyKQLwdnq1z5rj1GiVmkRF9yTjMLlOK1xCTG+gNgPvak0llMhj0
TDR_GAME_ICON_STORAGE_PATH=encrypted:BCMiSAwFOWy6zKZMyY5QA4fkVgTHEZ8JSDwpk8832G5iarY1btuwsy2Kk6lr43dqsg9eurzknouYOmyleTYun5yCHx+P+1PfUzE01JMBMd20cKj5yuMrO+qNA7KK3+Zib72NswGVvFhC8fKruSAzAkSeIdRo
APP_ORIGIN=encrypted:BGs9cTUlSzmPJhHESxefXKdzZ9w8l192He08Sl3IR4NXcNUlUuvxrDh0qEJXYX19GHtO1XkB95oXefO6Rw1H8SnZZ5zeeCHbOn+7oW/7v8dGLWkQMGTlG8YyJ6LNVAD1GBISAxjrO3e5lEAUOm/j0rvc8u24tg==
PASSKEY_RP_ID=encrypted:BI7ozl7Nw3NR3TlJ4TycJxt0HuNu7sQwNWFldib/sU+F6L60bNgENK60E9JXfx0euz7DwtOCFGeecKCtH7Vb7vIedT6aNyqXOtjmEi4zrEP12oIJJvd1nzU/2f2uBx6Hp+YwOyvhnMxGMg==
APP_ORIGIN=encrypted:BKVQPCrdHEjAt7SrT0Y/gtFveCKIp70LjbvCuv1HOTjYalHEPP0enrtiS4IYqbtHsV2mVQZ71y1k458WTJoP0LbezQ/9msoMuQGIop4EbtbCnuXZERtimGxZvIst0/JA4dfJUBx8Knsy+sZc1QaKmzBrbzQo3w==
PASSKEY_RP_ID=encrypted:BJxMVNfCOL9kErsgvPGSl3oYI9SyWVaP8gSQWggm9nVmLebxuHhQrdaewPDUED3Wduele51zZLvbUt80BFxZtdLFpQ3UwCWz9F12likanFmTdcJ7ILP7J5ITFD9GNNiUrQyKptG8qbZxUA==
DISCORD_CLIENT_ID="encrypted:BIcLXUW7R3kKKwL+vPqzDzdaF9FFc/o2LLW12za5X04VROQrPgt0nf+fChDvAE9zwd/iUcBGJO0kpYuYHPPxXzjrCRwa64oj+gxKuUtjqWDuJeos5ABYzG2ZPF9M4Lbtdiq7D/PAn4/vGXhKjUxhf+M5wTo="
DISCORD_CLIENT_SECRET="encrypted:BGANQ+E6ASvqsdW4Xvyh1hluWRQsyjtXJFJWma3pY00Ge9+kHZD7z9O9kWJKNKFGNoJP3G1bnGk1CT7x0g82R5X5jLx8YF8EETI3KjCybIctTwAGq+GepFpz7FuQIPhL3fNSteuF0eCC7kGTxTW2zrPyaLIDErF4+KCdDKRgoct/"
DISCORD_REDIRECT_URI=encrypted:BIVlGoCrhTUQE6e8j0Ol7oi/5DMHblTRfWiexZg24VZrD2t7gomiZn1ylMo0YYHNm2B7jf+TkRTlNg36wUukhkl5L+qQRGaVYuhiD57s8Hzv9Z+8zcREoqKu8jTtA8CKTYHFmaiwRfYQJAxNzWRCGNp1oAUd1TpXUEZ03hORaIgD6DJAu6OeaipofKs=
DISCORD_CLIENT_ID="encrypted:BPUCg9hzJyK2glzRIfXi2Ok1B9MUPIVNK7kOVRzfKHEmmsBFpQBYm7a2Qzbrjrg6j72KoMvw8oR3DnZEM4CUmNiYhjmoKyERyutV0BZ0+WuWgezykEFM0xFE/ERl/XTWytLm1/972Z272k/0bhambG5qvhg="
DISCORD_CLIENT_SECRET="encrypted:BGKAsGe/w5tvZDN/qWU6qHly1NDxugwkyqg+nBwf+5GtvaY30mhfymevELebjYXAfykHiJ5uTOUDW0GBWHVhEzvAAjCARQFnKfDE6PQeJv21wzAszOzS/YcikbeIvwjHavGqFDddvwJa2YvIjw+duh4b/CcrWMOUgP78IB2w+mYS"
DISCORD_REDIRECT_URI=encrypted:BNI/hR+kYuIcgBhw3hT3PEDOafhjk13jsixFQhl6XISy75/4yIY1UP3eHBT3ra5Rs5oimIIkeMXHbp5MfMGFKA90vN7YnT9XGus8POGVi1t3+4aNoB06ke1/SIvByb9SDoYQYo4XT7LVf29n0khY9Rub9UbXBr3ljy3yR3Z03wFQFVFEnHg5PeOXZPE=
SMTP_HOST="encrypted:BAzR0UdCJHlutOl01cw6kzPRseaot+cr+7ePUSKOcorXrGE4d2BWvQCT5A37qydjieSN98H1lV+9nykNMYUAD3PniY7/nd9DD6bDmXnG1nKACedCCF/fTPgfct0PMSN+FmgJI6UMbP45Q/l/QM3eRBJcqQ=="
SMTP_PORT="encrypted:BEfvu12wUPhrlVp1ICDAE6ohx5rKsUWGcnYnqL40VwafI8nayK9DqU5tuxag5du4E9lkHZ7QOLbn9QRw3sR3Yl74C+PlIktQWSQ5qCWMh2qbjYmulF8uTSWNXpPhIlhCyYVipA=="
SMTP_SECURE="encrypted:BHsWBT1w//boBqindVIcD3KOaDcjBfuHb+cm6ph5zP3dMT+oaFlG7msV28uPnbL5ZtOFXC6sUzVicMeqktu9O5ciQ+K9CFM+q+GStQBFqJ1SbfEq0isspV9eXWcTGPmTZAfBDxFx"
SMTP_USER="encrypted:BL5lI1+d91LAlbLyGCZ343Uqq55McVdF7Jo+e33EauaChfg+ltKB2d4lFGm7KHkQ+K9CorxlPpR82YjssS7ixYWwbEkFL7PgOgWxF8ksKQHDL8UR1zaqTmyylKseVfj/YIquaA7nS5FsjzpWCDVQFETJNw=="
SMTP_PASSWORD="encrypted:BNsDbxMZqE142HZ8PyEytL9mG7L2y79rMvM6aqDcs0ONNYtjM2ffDwKbu4ZySwkv6NS6mdKtHTVg3/tGmvdPRjbuK4f6FpRsJEnQvCwLaN0Gy9ikeCMOwrqQCOyfJSTkNTBiOvrz5e/Id0BXYjOkShk="
SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A=="
SMTP_HOST="encrypted:BIxKDxHcN3/bwHQVILQMP5FakAD6ixciFWK0kYfdSZl3YX1BVK8+KAPxNS2LXd83wI8swM+f8TJpaVYsE/teALY3rA67KgvyZH09nmH0VvuKmdA5jYia08m/ZXb69a1W5cWMEkzG/l+ERGG7jJD38aLKlA=="
SMTP_PORT="encrypted:BNzY9VsJiCyPiHD60VI8Lu+QgyA88G5Rxsnxo4Zc2cGiPdTjT6u4yjzlZS/m3DqRN3mHtY/C7p1qRRXu1uj/JRku6t7JiaEyZnoYS1cfdS0tpmwTiqhHzVlpBUiX+2ImpRfYOw=="
SMTP_SECURE="encrypted:BGzfmeSU+mRw/YpYAu7svFBzsWVwxjwCrnzQJQ4t6JKGVLTDCv4vdSYZBxO9fBvpJD0QQyyndnHxHAvlENMy2AEv2WaB2SICf5T5AUwvENH9kPkX5h/M43Diji3n30qh/Gm53pGo"
SMTP_USER="encrypted:BHR+Buh73QAfXrLWEJ0ipXStkQJuVhSdK6F5AW/9/XXcvvhd0WZnJXXIVg4niP6VkW6R+/IoW7jzkUR7VUez7Z44sOFWIfe2M3uxrEDCeu/zNmhZaV5YCuBXj/pcEVb3/eUAiMgI1NhUez2/MCw29gV1FA=="
SMTP_PASSWORD="encrypted:BJksY/oTv8MWWnN/I0YkEjhSLK94TqX0LrnukecdgSOLQoRydqB52gBD7iUwqQkiDs70LCsSsDROZnJvLgfI/OHWTpIr69t2cPCjAcQjBwsRFXAt5hmaTeAOAVlSPleuCNRqxHOHJckiH/alSB/9yPM="
SMTP_FROM="encrypted:BAuwcaQj+CpkOO3teZdpbkVP2P/5DffxIm4WRlkPAS6uw1jlLkPKOwrdNMms6w+QqvugvL44J9ScrK5bSfNryUBaupwY+hKisqi4zzEPcwEqmychyMP8O/m65lQsBYI9oH/BLruNns8jXBTtNBkMcY95hwAqL3FQHhAdGN7wdQ=="
ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB"
SCHEDULER_TOKEN="encrypted:BOQwJdKcGoxWA70HS/7Rpv6uQiupCw+QuY54At7zuDZFopdUCOj+lu2N9I8L+497bROGBsWG2knGzu4NXvb264PS6c5kk3UGrcctAPMalN4Cb9mAm/ZHeC0rvTkF0Lk50/2sesc9Zmi8Qess8+hSRdyAttt8V7YoT6b4kDsX6sK1ws+s6cd+IG7Oxfc="
ORIGIN="encrypted:BGcNYe6p2UIQF0clcq7riYodr67GX27F9tHDU4b3I85c2rXyE5c9igAQxZgZs5YXDIAt9FG3lHt/QsXYNFzSruwBE+7cXHMN8i6vGxDaxFepMjhDsLhDd911BnPZH0ENuc5GrtS6aAKXRj5kGz42eXh+Dpj2JqCI"
SCHEDULER_TOKEN="encrypted:BK3ud4X3Vsou3bURV/0iaK7Tye06UK8UJJy6i6rUsMJi7QcfFjuHYgNdupFCn6DRO6u0TzlpFSa87tnWQeKUaK+SRb+nxSrdGoejY3S5abqlOMxKTUyCGvoYQbECbto2PXasxEb4iDcpIi5ZPIupVk9CHz/jCmZDAlxp53bOyIQtXEMUdx0Er0VgFU4="
REMOVE_BG_API_KEY=encrypted:BJg92nPiZcW+3jPgds3uVIIvGGOJDhabNeHTdReragLV0S9AKfuktG3DG5pq7QSVLh3ZIsGOTgrFkp35nlIYX2KxYhFivpccmZ5AQSYoEHEKiBJkBMPwwkS+PXMAVEW8LWMg9ieapwBnd3z5UNuj+OH9T72gNBahWg==
+2 -1
View File
@@ -43,6 +43,7 @@ jobs:
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}"
@@ -53,7 +54,7 @@ jobs:
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\n' "$COMMIT_SHA" "$production_port" > "$deployment_path/.env.deploy"
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"
+2 -1
View File
@@ -42,6 +42,7 @@ jobs:
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
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
@@ -49,7 +50,7 @@ jobs:
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\n' "$COMMIT_SHA" "$stage_port" > "$deployment_path/.env.deploy"
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"
+2
View File
@@ -36,3 +36,5 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/_img
+87 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"generatedAt": "2026-07-26T15:12:23.344Z",
"generatedAt": "2026-07-26T17:47:28.721Z",
"title": "Design System: SHD.build",
"extensions": {
"colorMeta": {
@@ -10,24 +10,72 @@
"canonical": "#0a0c0d",
"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"]
},
"terminal-surface": {
"role": "neutral",
"displayName": "Terminal Surface",
"canonical": "#111415",
"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"]
},
"active-graphite": {
"role": "neutral",
"displayName": "Active Graphite",
"canonical": "#15191a",
"tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"]
},
"structural-line": {
"role": "neutral",
"displayName": "Structural Line",
"canonical": "#303536",
"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"]
},
"field-manual-white": {
"role": "neutral",
"displayName": "Field Manual White",
"canonical": "#ecebe5",
"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"]
},
"telemetry-dim": {
"role": "neutral",
"displayName": "Telemetry Dim",
"canonical": "#5d6567",
"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"]
},
"signal-orange-hover": {
"role": "primary",
"displayName": "Signal Orange Hover",
"canonical": "#ffc45d",
"tonalRamp": ["#2d1f08", "#51380d", "#785515", "#a97824", "#d99d39", "#ffc45d", "#ffdda0", "#fff3d9"]
},
"system-success": {
"role": "secondary",
"displayName": "System Success",
@@ -39,6 +87,36 @@
"displayName": "System Danger",
"canonical": "#df8069",
"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"]
},
"quality-standard": {
"role": "tertiary",
"displayName": "Standard Quality",
"canonical": "#69b66f",
"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"]
},
"quality-superior": {
"role": "tertiary",
"displayName": "Superior Quality",
"canonical": "#9b68d4",
"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"]
}
},
"typographyMeta": {
@@ -46,6 +124,14 @@
"displayName": "Operational Display",
"purpose": "Barlow for short public hero statements and major exception titles."
},
"headline": {
"displayName": "Operational Headline",
"purpose": "Barlow for routed-page identity and strong editorial section titles."
},
"title": {
"displayName": "Registry Title",
"purpose": "Barlow for module, panel, card, and record names."
},
"body": {
"displayName": "System Body",
"purpose": "Barlow for functional copy and general interface content."
+37 -3
View File
@@ -1,5 +1,39 @@
# Repository Instructions
## 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.
1. Delegate bounded mechanical work to faster, lower-reasoning agents. Suitable
tasks include focused code discovery, repetitive call-site updates,
documentation, and 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.
## SHD design-system maintenance
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens and patterns in `src/app.css`
@@ -86,7 +120,7 @@ Follow these rules:
`user_access`, `user_moderation`, `identity`, and `role_management`.
Actions describe the exact event in namespaced past tense, such as
`weapon.updated`, `attribute.archived`, or `moderation.blocked`.
4. Capture relationship changes as well as the primary row. When an action
4. Capture changes to the primary row and its relationships. When an action
replaces permission sets, compatible brands, talents, effects, tiers, or
other linked records, include the meaningful previous and resulting sets.
5. Create audit records only after validation succeeds and the requested
@@ -122,8 +156,8 @@ Treat embedded database files as persistent user data, not disposable build
artifacts.
Before deleting, replacing, recreating, renaming, or destructively migrating a
database—and before any major schema change—create a timestamped JSON backup
with `npm run db:backup`.
database, create a timestamped JSON backup with `npm run db:backup`. Create the
same backup before any major schema change.
Database work must follow these rules:
+77 -43
View File
@@ -114,17 +114,16 @@ components:
**Creative North Star: "The Strategic Operations Grid"**
SHD.build behaves like a dependable intelligence network shared by active
agents: precise, restrained, tactical, and structured around trustworthy data.
The interface communicates through alignment, density, operational labels, and
visible state rather than spectacle. Public surfaces can breathe and use larger
editorial hierarchy, while ISAC compresses the same identity into an efficient
control surface.
SHD.build serves active agents as a dependable intelligence network: precise,
restrained, tactical, and built around trustworthy data. Alignment, density,
operational labels, and visible state carry the interface. Public surfaces use
space and larger editorial hierarchy; ISAC uses the same identity in an
efficient control surface.
The world is technical without becoming sterile. Fine structural lines,
tonally layered black and graphite surfaces, cropped game iconography, clipped
silhouettes, and tactile state changes create its character. Theme language
supports comprehension; it never becomes military cosplay.
Fine structural lines, layered black and graphite surfaces, cropped game
iconography, clipped silhouettes, and tactile state changes give the interface
its technical character. Theme language supports comprehension and avoids
military cosplay.
**Key Characteristics:**
@@ -135,17 +134,54 @@ supports comprehension; it never becomes military cosplay.
- Explicit textual states paired with icons, color, and motion.
- Responsive density from mobile touch layouts through scaled 4K workspaces.
## Brand Identity
SHD.build uses the two-color Core Segments identity. The emblem connects the
orange Division signal with ISAC's role as the site's analytic system:
- Three unequal Signal Orange arc segments form the outer circle. Each segment
has square ends.
- The widest break sits in the upper-right quadrant and contains one detached
Signal Orange square, which represents a data packet.
- A Field Manual White open square forms the ISAC core at the center.
Keep the emblem in Signal Orange and Field Manual White on Operations Black,
Terminal Black, or another approved dark surface. SHD.build has no one-color
emblem. Do not close the arc gaps, equalize the segments, remove the data
packet, fill the ISAC core, add letters to the emblem, or recolor either part.
If a placement cannot reproduce both colors, use a dark holding surface or omit
the emblem.
The standard lockup pairs the emblem with the heavy angular `SHD.build`
wordmark. Set `SHD` in Field Manual White and `.build` in Signal Orange. Place
the uppercase descriptor `RESURGENCE DATA & TOOLS` below the wordmark in
restrained telemetry type. Do not substitute another descriptor.
Use the complete lockup in the 76px global header and shared footer. The header
uses a 38px emblem; keep the wordmark and descriptor compact enough to preserve
the navigation grid. Mobile headers may use the emblem alone when the complete
lockup would crowd navigation. Favicons and app icons use the emblem without
text. Small-size artwork must retain both colors, widen the arc gaps where the
pixel grid requires it, and keep the packet and open core distinct.
Treat the emblem as decorative when visible `SHD.build` text names the product.
Give an emblem-only home link the accessible name `SHD.build home`. Preserve
the approved colors and full opacity in focus, disabled, and compact contexts;
use text or control styling to communicate state instead of fading the mark.
The Field Manual White core and Signal Orange arcs must remain distinct from
their background at every delivered size.
## Colors
The palette combines Operations Black, Terminal Graphite, Field Manual White,
and rare Signal Orange with semantic system and item-quality signals.
The palette uses Operations Black, Terminal Graphite, Field Manual White, rare
Signal Orange, and semantic system and item-quality signals.
### Primary
- **Signal Orange:** Primary actions, current navigation, focus, selected
controls, important values, and SHD identity details.
- **Signal Orange Hover:** Brighter confirmation that an orange action is
interactive, never a second decorative accent.
- **Signal Orange Hover:** Confirms that an orange action accepts input; never
use it as a decorative accent.
### Secondary
@@ -173,11 +209,11 @@ and rare Signal Orange with semantic system and item-quality signals.
- **Telemetry Muted / Telemetry Dim:** Secondary labels and low-priority
operational metadata.
**The Orange Discipline Rule.** Signal Orange identifies action and emphasis;
it does not cover large decorative areas or compete with semantic state colors.
**The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep
it out of large decorative areas and away from semantic state colors.
**The Redundant Meaning Rule.** Color always travels with text, position, shape,
or an icon. No status or quality can depend on hue alone.
**The Redundant Meaning Rule.** Pair color with text, position, shape, or an
icon. A status or quality cannot depend on hue alone.
## Typography
@@ -187,11 +223,10 @@ sans-serif fallbacks)
sans-serif fallbacks)
**Editorial Font:** Source Serif 4 (with Georgia and serif fallbacks)
**Character:** Barlow gives the system voice a squared, compact, engineered
rhythm while remaining highly readable in dense interfaces. Source Serif 4 is
reserved for human-readable explanations, guide-like prose, and selected
descriptive passages so operational data and editorial knowledge remain
visibly distinct.
**Character:** Barlow gives the system a squared, compact, engineered voice
that remains readable in dense interfaces. Reserve Source Serif 4 for
explanations, guide-like prose, and selected descriptive passages. Keep
operational data distinct from editorial knowledge.
### Hierarchy
@@ -208,20 +243,19 @@ visibly distinct.
- **Label** (900, 0.625rem, 0.1em tracking): Uppercase telemetry, field labels,
statuses, metadata, and compact actions.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it.
Never use monospace merely to make an interface look technical.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do
not use monospace to make an interface look technical.
**The Telemetry Restraint Rule.** Uppercase tracked labels are short navigational
or state-bearing signals, not paragraph typography.
## Layout
The system is an aligned grid of operational regions rather than a collection
of floating cards. Public pages use centered containers up to 1640px with
responsive outer padding. ISAC uses a compact command header, sticky 210px
module rail, and a flexible registry workspace. Builder and account surfaces
use deliberate workbench columns that collapse into a task-appropriate mobile
sequence.
The system uses an aligned grid of operational regions. Public pages use
centered containers up to 1640px with responsive outer padding. ISAC uses a
compact command header, sticky 210px module rail, and flexible registry
workspace. Builder and account surfaces use workbench columns that collapse
into a task-appropriate mobile sequence.
Spacing follows a tight 5/8/12/20px rhythm with 24px as the standard panel
inset. Related controls stay close; modules and major sections receive clearer
@@ -235,8 +269,8 @@ touch-oriented stacks or controlled horizontal registries. Interactive mobile
targets are at least 44px where space permits.
Large displays use the shared display-scale progression: 1.15 at 2200px, 1.3 at
2800px, and 1.5 at 3400px. This preserves readable density rather than simply
expanding empty space.
2800px, and 1.5 at 3400px. The scale preserves readable density and avoids
empty space.
**The Connected Grid Rule.** Use borders, shared edges, and aligned tracks to
show relationships. Do not wrap every subsection in another card.
@@ -248,9 +282,9 @@ workbenches prioritize scanability and efficient repeated actions.
SHD.build uses structural layering. Depth comes from tonal surface changes,
fine borders, clipped corners, inset active rails, and controlled overlays.
Resting cards and panels do not float. Shadows are reserved for temporary
surfaces that must detach from the document—menus, dialogs, toasts, and routed
fault consoles—and use a clear downward offset with a soft black blur.
Resting cards and panels do not float. Use shadows for temporary surfaces that
detach from the document: menus, dialogs, toasts, and routed fault consoles.
Give each a clear downward offset and soft black blur.
### Shadow Vocabulary
@@ -273,21 +307,21 @@ dialogs, and navigation elements use square engineered geometry with one-pixel
structural borders. Select hero visuals and status consoles may use clipped
polygon corners when the silhouette reinforces an operational artifact.
Circular geometry is identity-specific: agent avatars, the SHD mark, compact
online indicators, and true radial or orbital data graphics. Pills are not a
general control shape.
Circular geometry identifies agent avatars, the SHD mark, compact online
indicators, and radial or orbital data graphics. Do not use pills as a general
control shape.
**The Engineered Edge Rule.** Square is the default and rounding must have
semantic justification.
**The Identity Circle Rule.** Circular crops belong to people, the SHD emblem,
and genuinely radial information—not arbitrary controls.
and radial information, not arbitrary controls.
## Components
Components feel tactile and technical: a press produces immediate physical
feedback, a request exposes pending text and disables repeat activation, and
focus is unmistakable without becoming ornamental.
Components feel tactile and technical. A press produces physical feedback; a
request exposes pending text and blocks repeat activation; focus remains clear
without ornament.
### Buttons
+47 -56
View File
@@ -8,44 +8,40 @@ web
## Users
SHD.build primarily serves active *The Division Resurgence* players who want
reliable game data, accurate loadout planning, build sharing, and community
guides.
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
community interactions, and manage their identity and notifications. Anonymous
players can explore public content and keep local builder drafts.
Trusted contributors maintain structured game data through ISAC. Oversight
reviewers validate proposed changes, moderators handle community safety, and
reviewers validate proposed changes. Moderators handle community safety, while
administrators manage users, roles, system configuration, and operations.
## Product Purpose
SHD.build is an independent community intelligence platform for *The Division
Resurgence*. It brings together a public game database, calculated loadout
builder, shared builds, detailed guides, news, and community participation.
Resurgence*. It includes a public game database, calculated loadout builder,
shared builds, guides, news, and community participation.
Success means players can confidently understand game systems, create accurate
loadouts, learn from other agents, and help keep information current as the game
changes.
Players should understand game systems, create accurate loadouts, learn from
other agents, and help keep information current as the game changes.
## Positioning
SHD.build uses one community-maintained, structured source of game data across
its public database, builder calculations, builds, guides, and contribution
workflow. ISAC lets non-technical contributors propose connected data changes
while Oversight reviews them before they become authoritative.
SHD.build uses one community-maintained, structured game-data source for its
public database, builder calculations, builds, guides, and contribution
workflow. ISAC lets non-technical contributors propose connected data changes.
Oversight reviews proposals before they become authoritative.
This makes SHD.build more than a loadout tool or static wiki: player-facing
planning and reference experiences remain connected to the same moderated data.
Player-facing planning and reference experiences use the same moderated data.
## Operating Context
Players use the site while planning characters, comparing drops, reproducing
in-game equipment, browsing community recommendations, and responding to game
updates. The interface must work from mobile devices through high-resolution
desktop displays.
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.
The principal public channels are News, Database, Builds, Guides, and Builder.
Account management includes authentication methods, profile identity, avatars,
@@ -53,16 +49,15 @@ notifications, created content, favorites, and privacy controls.
ISAC is the operational console for game-data entry, contribution review, user
and role management, moderation, audit history, automated messages, and system
metrics. Registered agents begin with read-only access and may explicitly
enable contributor mode after accepting the contributor guidelines. Proposed
records may depend on other pending records so contributors can build connected
data chains before approval.
metrics. Registered agents begin with read-only access and can enable
contributor mode after accepting the contributor guidelines. Contributors can
connect proposed records to pending records before approval.
## Capabilities and Constraints
- The application is built with SvelteKit and Svelte.
- Persistent application data uses the embedded `@tursodatabase/database`
package rather than Turso Cloud.
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
deployments and additive schema migrations.
@@ -73,9 +68,9 @@ data chains before approval.
requires an account.
- Saved builds support private, unlisted, and public visibility.
- User deletion is soft deletion so authored builds and guides can remain while
login identities and reusable usernames are released safely.
- User-uploaded avatars are normalized for storage and enter an Oversight queue
without blocking immediate use.
the system releases login identities and reusable usernames safely.
- The application normalizes user-uploaded avatars for storage and sends them to
an Oversight queue without blocking immediate use.
- ISAC mutations are permission-controlled, append-only audited, and use
moderation proposals when the operator lacks direct-write authority.
- Game-data editors must preserve the standardized add/edit lifecycle described
@@ -88,14 +83,12 @@ data chains before approval.
## Brand Commitments
The product name is `SHD.build`, with `SHD` always capitalized. The product is
positioned as a complete *The Division Resurgence* community rather than only a
loadout network.
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
full *The Division Resurgence* community, including loadout planning.
The established voice uses restrained in-universe operational language such as
agent, ISAC, Oversight, intelligence, access, transmission, and registry when
it improves comprehension. Theme must never obscure plain meaning or turn into
military cosplay.
Use restrained in-universe operational language such as agent, ISAC, Oversight,
intelligence, access, transmission, and registry when it improves comprehension.
Do not let the theme obscure plain meaning or turn into military cosplay.
The existing SHD logo, favicon, game-type icon library, quality colors, and
provider branding are maintained assets. Discord, Google, Apple, and Steam
@@ -103,8 +96,7 @@ identity controls must follow their respective provider requirements.
## Evidence on Hand
- `docs/DESIGN_SYSTEM.md` is the maintained authority for the incumbent visual
and interaction language.
- `docs/DESIGN_SYSTEM.md` defines the current visual and interaction language.
- `docs/architecture.md` documents the application architecture and staged
feature model.
- `docs/STAT_LEVEL_SCALING.md` contains empirically derived equipment-scaling
@@ -117,35 +109,34 @@ identity controls must follow their respective provider requirements.
category icons, attribute icons, specialization icons, and shared fallback
artwork.
- The embedded development database contains contributor-entered game and user
data. It is evidence and persistent user data, not a disposable fixture.
data. Treat it as evidence and persistent user data rather than a disposable
fixture.
- No testimonials, player-count claims, official Ubisoft endorsement, or
performance benchmarks are established; future work must not fabricate them.
## Product Principles
1. **One source of operational truth.** Public reference data, builder
calculations, and contributed updates must remain connected rather than
becoming divergent copies.
2. **Make deep systems understandable.** Dense game mechanics should be
explorable and precise without requiring users to understand the underlying
data model.
3. **Contribution without technical gatekeeping.** Players should be able to
improve the database through clear forms, dependencies, review status, and
actionable Oversight feedback.
4. **Trust through visible governance.** Permissions, moderation, audit
history, privacy, and system messages should make consequential states and
1. **One source of operational truth.** Keep public reference data, builder
calculations, and contributed updates connected.
2. **Make deep systems understandable.** Present dense game mechanics with
precision without requiring users to understand the underlying data model.
3. **Contribution without technical gatekeeping.** Let players improve the
database through clear forms, dependencies, review status, and actionable
Oversight feedback.
4. **Trust through visible governance.** Use permissions, moderation, audit
history, privacy, and system messages to make consequential states and
actions explicit.
5. **Preserve the community's work.** User content, contributor-entered data,
and operational history must survive deployments, migrations, archival, and
account lifecycle changes whenever policy permits.
5. **Preserve the community's work.** Keep user content, contributor-entered
data, and operational history through deployments, migrations, archival, and
account lifecycle changes when policy permits.
## Accessibility & Inclusion
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 remain functional from mobile
screens through 4K desktop displays.
reduced-motion preferences, and layouts that work from mobile screens through
4K desktop displays.
State and meaning must never depend on color, icons, hover, or motion alone.
Controls need usable touch targets, and user identity must support non-ASCII
display names and diverse avatar representation.
State and meaning cannot depend on color, icons, hover, or motion alone.
Controls need usable touch targets. User identity must support non-ASCII display
names and diverse avatar representation.
+44 -46
View File
@@ -1,13 +1,15 @@
# SHD.build
A SvelteKit application for creating and sharing loadouts for *The Division Resurgence*.
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.
The first increment includes a responsive discovery page, an anonymous builder draft saved to local storage, authentication UI boundaries, an admin data-management preview, and an embedded Turso database foundation. See `docs/architecture.md` for the staged implementation plan.
See `docs/architecture.md` for the implementation model.
## Database
The server uses the in-process `@tursodatabase/database` package with a local
SQLite-compatible file—there is no Turso Cloud dependency.
The server uses the in-process `@tursodatabase/database` package and a local
SQLite-compatible file. Turso Cloud is not required.
Create or update the local database:
@@ -15,10 +17,10 @@ Create or update the local database:
npm run db:migrate
```
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` to
override its location.
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
set a different location if needed.
Custom avatars are stored as metadata-free 256 × 256 WebP files. Set
Store custom avatars as metadata-free 256 × 256 WebP files. Set
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
it defaults to `data/avatars`. The same configuration works on Windows and
Linux.
@@ -29,9 +31,9 @@ Seed the initial local accounts once:
npm run db:seed-users
```
This creates administrator `Aewyn` and standard user `Quartermaster`. Strong
temporary passwords are generated and displayed once; only salted Argon2id
hashes are stored.
The seed creates administrator `Aewyn` and standard user `Quartermaster`.
It generates and displays strong temporary passwords once, then stores only
salted Argon2id hashes.
## Develop
@@ -41,9 +43,9 @@ npm run dev
## Encrypted environment files
Development, staging, and production settings live in the committed, encrypted
`.env.development`, `.env.staging`, and `.env.production` files. Private
decryption keys live only in the ignored `.env.keys` file; back it up in a
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.
```sh
@@ -56,42 +58,38 @@ npm run env:decrypt:production
npm run env:sync:staging-credentials # copy shared Discord/SMTP values from development
```
The repository pre-commit hook runs `env:stage`, so a commit always replaces
staged plaintext environment files with encrypted versions. Git does not expose
a pre-add hook, so use `npm run env:stage` instead of directly adding these two
files when possible.
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.
Never commit or lose `.env.keys`. Production should receive
`DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying
the keys file onto the server.
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.
## Containers and staging deployment
The application uses the official SvelteKit Node adapter and includes a
multi-stage `Dockerfile`. Database files, database backups, user avatars, and
uploaded game-data icons live under `/app/data`, which must be a persistent
volume. Runtime secrets are injected when the container starts and are never
baked into the image.
The application uses the official SvelteKit Node adapter and a multi-stage
`Dockerfile`. Keep database files, database backups, user avatars, and uploaded
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
when the container starts; keep them out of the image.
The staging compose topology and Gitea Actions deployment are documented in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Pushes to `main` validate the
application, build the candidate image while staging remains online, stop the
application and scheduler briefly, back up the embedded database, migrate it,
and start the new release.
[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.
## Passwordless authentication
Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the
public origin used for registration and authentication, while `PASSKEY_RP_ID`
must be that hostname or a registrable parent domain. The hosted environments
use `shd.build`, allowing credentials on both `shd.build` and
`dev.shd.build`. Direct localhost development should use `localhost`.
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.
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
`SMTP_PASSWORD`, and `SMTP_FROM`. Port 465 normally uses
`SMTP_SECURE=true`; port 587 normally uses `false` and upgrades with STARTTLS.
Email identities become usable only after the account owner follows a
15-minute, single-use verification link.
`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.
## Validate
@@ -100,12 +98,10 @@ npm run check
npm run build
```
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
[`sv`](https://github.com/sveltejs/cli) creates and configures Svelte projects.
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
@@ -120,7 +116,8 @@ npx sv@0.16.5 create --template minimal --types ts --install npm .
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
Install dependencies with `npm install` (or `pnpm install` or `yarn`), then
start a development server:
```sh
npm run dev
@@ -131,15 +128,16 @@ npm run dev -- --open
## Building
To create a production version of your app:
Build a production version:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
Preview the production build with `npm run preview`.
> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for the target environment.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
The shared visual language and dashboard conventions are documented in
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
+362 -4
View File
@@ -123,7 +123,7 @@ 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, 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', 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 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));
@@ -240,7 +240,9 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images-manage','images.manage','View, upload, rename, tag, and categorize managed image assets');
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-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
@@ -276,7 +278,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
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,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')),
module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
@@ -321,6 +323,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_login_username_nocase_idx ON users(login
CREATE TABLE IF NOT EXISTS stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')),
@@ -342,7 +345,7 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
CHECK(cap_value IS NULL OR cap_value >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_nocase_idx ON stat_attributes(name COLLATE NOCASE);
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
CREATE TABLE IF NOT EXISTS talents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -618,11 +621,13 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects (
CREATE TABLE IF NOT EXISTS skill_chip_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active);
CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
id TEXT PRIMARY KEY,
skill_chip_set_id TEXT NOT NULL UNIQUE REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
@@ -775,11 +780,364 @@ CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
);
CREATE TABLE IF NOT EXISTS specialization_progression_config (
id TEXT PRIMARY KEY CHECK(id='default'),
xp_points INTEGER NOT NULL CHECK(xp_points>=0),
shd_cache_points INTEGER NOT NULL CHECK(shd_cache_points>=0),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO specialization_progression_config(id,xp_points,shd_cache_points,updated_at)
VALUES('default',30,19,'2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS specialization_rank_cost_profiles (
rank INTEGER PRIMARY KEY CHECK(rank BETWEEN 1 AND 3),
point_cost INTEGER NOT NULL CHECK(point_cost>0),
credit_cost INTEGER NOT NULL CHECK(credit_cost>=0),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(1,1,2333,'2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(2,2,10000,'2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO specialization_rank_cost_profiles(rank,point_cost,credit_cost,updated_at)
VALUES(3,3,90000,'2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS upgrade_resources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK(resource_type IN ('Currency','Component','Progression')),
quality TEXT CHECK(quality IS NULL OR quality IN ('Standard','Specialized','Superior','High-end')),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS upgrade_resources_name_nocase_idx ON upgrade_resources(name COLLATE NOCASE);
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-credits','Credits','Currency',NULL,'2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-alpha','Electronics Alpha Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-beta','Electronics Beta Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-gamma','Electronics Gamma Component','Component','Specialized','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-electronics-omega','Electronics Omega Component','Component','High-end','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO upgrade_resources(id,name,resource_type,quality,created_at,updated_at)
VALUES('resource-shd-tech-cache','SHD Tech Cache','Progression','Superior','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
CREATE TABLE IF NOT EXISTS specializations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS specializations_name_nocase_idx ON specializations(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS specialization_focuses (
id TEXT PRIMARY KEY,
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
primary_attribute TEXT NOT NULL CHECK(primary_attribute IN ('Firepower','Toughness','Engineering')),
display_order INTEGER NOT NULL DEFAULT 0 CHECK(display_order>=0),
builder_ready INTEGER NOT NULL DEFAULT 0 CHECK(builder_ready IN (0,1)),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(specialization_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_focuses_specialization_idx ON specialization_focuses(specialization_id,display_order,name);
CREATE TABLE IF NOT EXISTS specialization_abilities (
id TEXT PRIMARY KEY,
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL,
duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0),
cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(specialization_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
label TEXT NOT NULL,
value REAL,
unit TEXT,
UNIQUE(ability_id,effect_order)
);
CREATE TABLE IF NOT EXISTS specialization_focus_abilities (
focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE,
ability_slot INTEGER NOT NULL CHECK(ability_slot BETWEEN 1 AND 3),
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
PRIMARY KEY(focus_id,ability_slot),
UNIQUE(focus_id,ability_id)
);
CREATE TABLE IF NOT EXISTS specialization_tree_nodes (
id TEXT PRIMARY KEY,
focus_id TEXT NOT NULL REFERENCES specialization_focuses(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description_template TEXT NOT NULL,
row_number INTEGER NOT NULL CHECK(row_number>=1),
column_number INTEGER NOT NULL CHECK(column_number>=1),
max_rank INTEGER NOT NULL CHECK(max_rank BETWEEN 1 AND 3),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(focus_id,row_number,column_number),
UNIQUE(focus_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_tree_nodes_focus_idx ON specialization_tree_nodes(focus_id,row_number,column_number);
CREATE TABLE IF NOT EXISTS specialization_tree_node_ranks (
node_id TEXT NOT NULL REFERENCES specialization_tree_nodes(id) ON DELETE CASCADE,
rank INTEGER NOT NULL CHECK(rank BETWEEN 1 AND 3),
description TEXT NOT NULL,
PRIMARY KEY(node_id,rank)
);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_effects (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
rank INTEGER NOT NULL,
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
effect_type TEXT NOT NULL CHECK(effect_type IN ('Attribute','Ability','Custom')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
ability_id TEXT REFERENCES specialization_abilities(id),
label TEXT NOT NULL,
value REAL,
unit TEXT,
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE,
UNIQUE(node_id,rank,effect_order),
CHECK(
(effect_type='Attribute' AND stat_attribute_id IS NOT NULL)
OR (effect_type='Ability' AND ability_id IS NOT NULL)
OR effect_type='Custom'
)
);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_costs (
node_id TEXT NOT NULL,
rank INTEGER NOT NULL,
resource_id TEXT NOT NULL REFERENCES upgrade_resources(id),
quantity INTEGER NOT NULL CHECK(quantity>0),
PRIMARY KEY(node_id,rank,resource_id),
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS specialization_combat_effects (
id TEXT PRIMARY KEY,
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description_template TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(specialization_id,name)
);
CREATE INDEX IF NOT EXISTS specialization_combat_effects_specialization_idx
ON specialization_combat_effects(specialization_id,active,name);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_combat_effects (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
rank INTEGER NOT NULL,
combat_effect_id TEXT NOT NULL REFERENCES specialization_combat_effects(id),
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
trigger_type TEXT NOT NULL CHECK(trigger_type IN ('enemy_hits_within_window')),
trigger_count INTEGER NOT NULL CHECK(trigger_count>0),
trigger_window_seconds REAL NOT NULL CHECK(trigger_window_seconds>0),
duration_seconds REAL NOT NULL CHECK(duration_seconds>0),
max_stacks INTEGER NOT NULL CHECK(max_stacks>0),
stacks_per_trigger INTEGER NOT NULL DEFAULT 1 CHECK(stacks_per_trigger>0),
counter_policy TEXT NOT NULL DEFAULT 'reset_on_proc' CHECK(counter_policy IN ('reset_on_proc')),
refresh_policy TEXT NOT NULL DEFAULT 'refresh_on_stack_gain' CHECK(refresh_policy IN ('refresh_on_stack_gain')),
expiry_policy TEXT NOT NULL DEFAULT 'clear_all' CHECK(expiry_policy IN ('clear_all')),
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE,
UNIQUE(node_id,rank,effect_order)
);
CREATE INDEX IF NOT EXISTS specialization_tree_rank_combat_effects_effect_idx
ON specialization_tree_rank_combat_effects(combat_effect_id);
CREATE TABLE IF NOT EXISTS specialization_tree_rank_combat_modifiers (
id TEXT PRIMARY KEY,
rank_combat_effect_id TEXT NOT NULL REFERENCES specialization_tree_rank_combat_effects(id) ON DELETE CASCADE,
modifier_order INTEGER NOT NULL CHECK(modifier_order>=1),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
value_per_stack REAL NOT NULL,
unit TEXT,
UNIQUE(rank_combat_effect_id,modifier_order)
);
CREATE TABLE IF NOT EXISTS calculation_revisions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL UNIQUE CHECK(version>0),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','retired')),
notes TEXT,
based_on_revision_id TEXT REFERENCES calculation_revisions(id),
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL,
published_by TEXT REFERENCES users(id),
published_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS calculation_revisions_active_idx
ON calculation_revisions(status) WHERE status='active';
CREATE TABLE IF NOT EXISTS calculation_stages (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL,
label TEXT NOT NULL,
operator TEXT NOT NULL CHECK(operator IN (
'base_damage','flat_add','additive_percent','multiplicative_percent',
'expected_critical','expected_headshot','rate_of_fire','magazine_size',
'reload_speed','firing_cycle'
)),
display_order INTEGER NOT NULL CHECK(display_order>=1),
locked_position TEXT CHECK(locked_position IS NULL OR locked_position IN ('first','last')),
UNIQUE(revision_id,stage_key),
UNIQUE(revision_id,display_order)
);
CREATE TABLE IF NOT EXISTS attribute_calculation_profiles (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
stage_key TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1,
soft_cap REAL,
hard_cap REAL,
curve_type TEXT NOT NULL DEFAULT 'none' CHECK(curve_type IN ('none','power','smoothstep')),
curve_parameter REAL,
weapon_category TEXT NOT NULL DEFAULT '*',
UNIQUE(revision_id,stat_attribute_id,stage_key,weapon_category),
FOREIGN KEY(revision_id,stage_key) REFERENCES calculation_stages(revision_id,stage_key) ON DELETE CASCADE,
CHECK(soft_cap IS NULL OR soft_cap>=0),
CHECK(hard_cap IS NULL OR hard_cap>=0),
CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap)
);
CREATE TABLE IF NOT EXISTS calculation_calibration_cases (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','ready')),
loadout_snapshot_json TEXT NOT NULL,
selected_weapon INTEGER NOT NULL DEFAULT 0 CHECK(selected_weapon IN (0,1)),
game_version TEXT,
observed_at TEXT,
notes TEXT,
body_hit REAL,
critical_body_hit REAL,
headshot REAL,
critical_headshot REAL,
magazine_seconds REAL,
reload_seconds REAL,
encounter_dps REAL,
predicted_json TEXT,
calculation_revision_id TEXT REFERENCES calculation_revisions(id),
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK(body_hit IS NULL OR body_hit>=0),
CHECK(critical_body_hit IS NULL OR critical_body_hit>=0),
CHECK(headshot IS NULL OR headshot>=0),
CHECK(critical_headshot IS NULL OR critical_headshot>=0),
CHECK(magazine_seconds IS NULL OR magazine_seconds>0),
CHECK(reload_seconds IS NULL OR reload_seconds>0),
CHECK(encounter_dps IS NULL OR encounter_dps>=0)
);
CREATE INDEX IF NOT EXISTS calculation_calibration_status_idx
ON calculation_calibration_cases(status,updated_at DESC);
INSERT OR IGNORE INTO calculation_revisions(id,version,name,status,notes,created_at,published_at)
VALUES('calculation-revision-1',1,'Initial combat model','active','Typed baseline combat stages','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
VALUES('calculation-stage-base-1','calculation-revision-1','base_damage','Base damage','base_damage',1,'first');
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-flat-1','calculation-revision-1','flat_damage','Flat damage','flat_add',2);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-additive-1','calculation-revision-1','additive_damage','Additive damage','additive_percent',3);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-multiplicative-1','calculation-revision-1','multiplicative_damage','Multiplicative damage','multiplicative_percent',4);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-critical-chance-1','calculation-revision-1','critical_chance','Critical chance','expected_critical',5);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-critical-1','calculation-revision-1','critical_damage','Critical damage','expected_critical',6);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-headshot-chance-1','calculation-revision-1','headshot_chance','Headshot chance','expected_headshot',7);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
VALUES('calculation-stage-headshot-1','calculation-revision-1','headshot_damage','Headshot damage','expected_headshot',8);
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
CREATE TABLE IF NOT EXISTS game_data_icons (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
);
CREATE TABLE IF NOT EXISTS managed_images (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
is_icon INTEGER NOT NULL DEFAULT 0 CHECK(is_icon IN (0,1)),
force_white INTEGER NOT NULL DEFAULT 0 CHECK(force_white IN (0,1)),
storage_key TEXT NOT NULL UNIQUE,
original_filename TEXT NOT NULL,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
width INTEGER NOT NULL CHECK(width > 0),
height INTEGER NOT NULL CHECK(height > 0),
source_entity_type TEXT,
source_entity_id TEXT,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS managed_images_name_nocase_idx ON managed_images(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS managed_images_category_idx ON managed_images(category);
CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx
ON managed_images(source_entity_type,source_entity_id)
WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS external_service_status (
provider TEXT PRIMARY KEY,
credits_total REAL,
credits_subscription REAL,
credits_payg REAL,
credits_enterprise REAL,
free_calls INTEGER,
checked_at TEXT,
next_check_at TEXT,
last_charge REAL,
last_charge_at TEXT,
error_message TEXT,
severity TEXT NOT NULL DEFAULT 'unknown' CHECK(severity IN ('unknown','ok','warning','critical')),
episode_id TEXT,
episode_started_at TEXT,
recovered_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS external_service_alert_deliveries (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL REFERENCES external_service_status(provider),
episode_id TEXT NOT NULL,
severity TEXT NOT NULL CHECK(severity IN ('warning','critical')),
user_id TEXT NOT NULL REFERENCES users(id),
notification_id TEXT REFERENCES notifications(id),
created_at TEXT NOT NULL,
delivered_at TEXT,
UNIQUE(provider,episode_id,severity,user_id)
);
CREATE INDEX IF NOT EXISTS external_service_alert_pending_idx
ON external_service_alert_deliveries(delivered_at,created_at);
CREATE TABLE IF NOT EXISTS site_launch_settings (
id TEXT PRIMARY KEY CHECK(id='primary'),
coming_soon_enabled INTEGER NOT NULL DEFAULT 1 CHECK(coming_soon_enabled IN (0,1)),
account_creation_enabled INTEGER NOT NULL DEFAULT 1 CHECK(account_creation_enabled IN (0,1)),
news_enabled INTEGER NOT NULL DEFAULT 1 CHECK(news_enabled IN (0,1)),
database_enabled INTEGER NOT NULL DEFAULT 1 CHECK(database_enabled IN (0,1)),
builds_enabled INTEGER NOT NULL DEFAULT 1 CHECK(builds_enabled IN (0,1)),
guides_enabled INTEGER NOT NULL DEFAULT 1 CHECK(guides_enabled IN (0,1)),
builder_enabled INTEGER NOT NULL DEFAULT 1 CHECK(builder_enabled IN (0,1)),
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
);
INSERT OR IGNORE INTO site_launch_settings(
id,coming_soon_enabled,account_creation_enabled,news_enabled,database_enabled,
builds_enabled,guides_enabled,builder_enabled,updated_at
) VALUES('primary',1,1,1,1,1,1,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO permissions(id,name,description)
VALUES('permission-site-settings-manage','site.settings.manage','Manage launch mode, account creation, and public section availability');
+1
View File
@@ -15,6 +15,7 @@ services:
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
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
APP_ORIGIN: https://shd.build
ORIGIN: https://shd.build
PASSKEY_RP_ID: shd.build
+1
View File
@@ -15,6 +15,7 @@ services:
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
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
ports:
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
volumes:
+142 -35
View File
@@ -1,9 +1,9 @@
# Container, staging, and production deployment
SHD.build is packaged as a Node 24 container using the official SvelteKit Node
adapter. The image contains application code, migration and notification
scripts, and native runtime dependencies. It does not contain an environment
file, database, backups, or user uploads.
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.
## Persistent data
@@ -15,10 +15,9 @@ mounted at `/app/data`. It contains:
- normalized user avatars under `avatars/`;
- uploaded game-data icons under `game-icons/`.
Never remove this volume during routine deployments. `docker compose down`
without `--volumes` preserves it. Before any manual backup or restore, stop
both the `app` and `scheduler` services because both access the embedded
database.
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.
## Staging environment
@@ -46,9 +45,9 @@ 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 deployment decrypts `.env.staging` on the trusted staging runner into a
mode-`0600` runtime file. The decryption key is held by Gitea Actions, not
committed or included in the container image.
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 staging container disables default-user seeding. On a brand-new staging
database, the existing bootstrap policy grants Admin to the first real account
@@ -93,9 +92,11 @@ In `https://git.jb.codes/jb/shd.build/settings/actions`:
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
4. Ensure Actions are enabled and the `stage-deploy` runner is online.
The workflow at `.gitea/workflows/deploy-staging.yml` runs on every push to
`main` and can also be started manually. It does not need registry credentials
because the trusted staging runner builds a commit-tagged image locally.
`.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.
## Deployment lifecycle
@@ -110,21 +111,64 @@ The workflow:
7. waits for `/api/health/database` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails.
The short outage is limited to backup, migration, and container startup. This
follows the database-preservation policy while minimizing data-entry downtime.
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.
## Prelaunch operation
The launch-control migration creates `site_launch_settings` with these defaults
for a database that does not contain the row:
- Coming Soon: enabled
- Account creation: enabled
- 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
imports leave the target environment's launch settings unchanged.
Use this operator flow after the first deployment:
1. Open `/sign-in`; the launch gate keeps that route available.
2. Sign in with an existing Administrator account.
3. Open ISAC → System Configuration → Site Controls.
4. Set account creation and public-section availability.
5. Review the complete change set, then confirm it.
6. Use `PREVIEW PAGE` to inspect `/coming-soon` with the authenticated
`ENTER FULL SITE` return action. The preview lists each section's current
availability.
7. Verify `/coming-soon` and the affected public routes in a signed-out browser
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.
Turn Coming Soon on to restore the promotional gate. Site-control changes append
`system_configuration / site_access.updated` audit events, including the
operator and complete before/after settings.
## Initial development-data bootstrap
The first staging release can be initialized from the current development
database, including users, roles, builds, guides, proposals, audit history,
avatars, and uploaded game-data icons. This is deliberately a one-time flow:
the staging importer refuses to run after users, builds, audits, or proposals
exist in its target database.
Initialize the first staging release from the development database, including
users, roles, builds, guides, proposals, audit history, avatars, and uploaded
game-data icons. The staging importer refuses a target database that contains
users, builds, audits, or proposals. It also requires the source and target
table sets to match exactly; snapshots with retired or unknown tables are
rejected.
First stop this project's development server. The embedded database must not be
open while its complete transfer bundle is created:
@@ -134,8 +178,8 @@ npm run stage:bootstrap:export
```
The command writes a timestamped, ignored JSON bundle under `data/backups/`.
It contains user records and password hashes, so transfer it over an encrypted
channel and never add it to Git. Copy it to the staging host, then run:
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 \
@@ -160,8 +204,8 @@ Actions. It briefly stops staging database writers, exports only canonical
builder-source tables plus uploaded game-data icons, restarts staging, and
publishes a `shd-stage-isac-data` artifact for 14 days.
Download `isac-stage.json` from that workflow run. Then stop this project's
development server and run:
Download `isac-stage.json` from that workflow run, stop this project's
development server, and run:
```sh
npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data
@@ -206,10 +250,10 @@ environment, port, and deployment workflow:
- Database: `/app/data/shd-build-production.db`
- Persistent volume: `shd-build-production-data`
Routine pushes never import or replace production data. The production deploy
stops only its application and scheduler, creates a timestamped JSON backup,
applies repeatable migrations, restarts, and verifies database health. Never
run `docker compose down --volumes` against production.
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.
Add these repository Action secrets:
@@ -220,7 +264,8 @@ Add these repository Action secrets:
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
`deploy-production.yml` workflow deploys production on pushes to `main` and
manual dispatches using the trusted `stage-deploy` host runner.
manual dispatches using the trusted `stage-deploy` host runner. Its Gitea
workflow run number supplies the production `SHD_BUILD_NUMBER`.
Prepare the host directory once from an interactive SSH session:
@@ -234,8 +279,8 @@ reuse the staging scheduler token.
### Initial production bootstrap
The one-time production bootstrap clones current development data and uploads.
Stop this project's development server before creating the bundle:
The production bootstrap clones current development data and uploads. Stop this
project's development server before creating the bundle:
```sh
npm run production:bootstrap:export
@@ -269,6 +314,68 @@ notifications, settings, and audit history untouched.
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 both full and
ISAC-only safety backups before replacement. This is intentionally manual and
never runs as part of either deployment.
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.
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.
```sh
npm run images:export
```
The command writes a timestamped JSON bundle under `data/backups/`. It contains
only managed-image metadata and processed WebP files. Transfer that bundle to
the deployment host, stop the target `app` and `scheduler` services so the
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
# 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
```
Each import creates a full target database backup before changes. The importer
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.
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:
```sh
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
npm run images:import:production -- /path/to/bundle.json --confirm-merge
```
+523 -137
View File
@@ -1,15 +1,15 @@
# SHD.build Design System
This document defines the visual language for SHD.build, the independent
Division Resurgence community for builds, guides, game data, news, and
discussion. New pages should feel like part of the same tactical intelligence
network as the admin dashboard, while public pages may use more space and larger
editorial typography.
This document defines the visual language for SHD.build, an independent
Division Resurgence data-and-tools site for builds, guides, game data, news,
and discussion. New pages use the same tactical intelligence network as the
admin dashboard. Public pages may use more space and larger editorial
typography.
## Design principles
1. **Tactical, not military cosplay.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, or decorative weapon imagery in utility interfaces.
2. **Data first.** Hierarchy comes from grouping, typography, borders, and spacing. Admin tools should resemble a dependable control surface rather than a collection of marketing cards.
1. **Tactical.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, and decorative weapon imagery in utility interfaces.
2. **Data first.** Grouping, typography, borders, and spacing establish hierarchy. Admin tools use a dependable control surface.
3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas.
4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators.
5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment.
@@ -17,45 +17,81 @@ editorial typography.
underlines. Communicate hover and focus through SHD accent color, borders,
brightness, and the required focus-visible outline.
## Brand identity
SHD.build uses the two-color Core Segments identity. The emblem combines the
orange Division signal with ISAC's analytic role in the product:
- Three unequal Signal Orange arc segments form the outer circle. Each segment
has square ends.
- The widest break sits in the upper-right quadrant and contains one detached
Signal Orange square, which represents a data packet.
- A Field Manual White open square forms the ISAC core at the center.
Keep the emblem in Signal Orange and Field Manual White on Operations Black,
Terminal Black, or another approved dark surface. SHD.build has no one-color
emblem. Do not close the arc gaps, equalize the segments, remove the data
packet, fill the ISAC core, add letters to the emblem, or recolor either part.
Use a dark holding surface or omit the emblem when a placement cannot reproduce
both colors.
The standard lockup pairs the emblem with the heavy angular `SHD.build`
wordmark. Set `SHD` in Field Manual White and `.build` in Signal Orange. Place
the uppercase descriptor `RESURGENCE DATA & TOOLS` below the wordmark in
restrained telemetry type. Do not substitute another descriptor.
Use the complete lockup in the 76px global header and shared footer. The header
uses a 38px emblem; keep the wordmark and descriptor compact enough to preserve
the navigation grid. Mobile headers may use the emblem alone when the complete
lockup would crowd navigation. Favicons and app icons use the emblem without
text. Small-size artwork must retain both colors, widen the arc gaps where the
pixel grid requires it, and keep the packet and open core distinct.
Treat the emblem as decorative when visible `SHD.build` text names the product.
Give an emblem-only home link the accessible name `SHD.build home`. Preserve
the approved colors and full opacity in focus, disabled, and compact contexts;
use text or control styling to communicate state instead of fading the mark.
The Field Manual White core and Signal Orange arcs must remain distinct from
their background at every delivered size.
### Routed error console
Expected HTTP errors and unexpected server faults use one shared SHD exception
console inside the normal application shell. The console always displays the
console inside the application shell. The console displays the
numeric status, an operational title, a safe explanation, request path, node,
and context-appropriate recovery controls. Common statuses receive distinct
language: authentication for 401, clearance for 403, lost location for 404,
conflict for 409, saturation for 429, and service/fault language for 5xx.
Orange identifies recoverable request errors and actions; red identifies server
faults. Never display stack traces, database messages, credentials, request
queries, or raw unexpected exceptions to users. Unexpected server faults receive
a short reference ID from the global error handler for support correlation while
the complete error remains server-side. If the root application shell itself
cannot render, `src/error.html` provides a dependency-free SHD fallback surface.
faults. Do not display stack traces, database messages, credentials, request
queries, or raw unexpected exceptions. The global error handler gives each
unexpected server fault a short support reference ID and keeps the full error
server-side. If the root application shell cannot render, `src/error.html`
provides a dependency-free SHD fallback surface.
Error controls retain square geometry, focus-visible treatment, pressed motion,
plain-language labels, and stacked mobile recovery actions.
### Contact relay and legal requests
Public support uses the SHD Contact Relay surface: a broad transmission form
paired with a compact destination guide. Two-to-six destinations use the shared
segmented-choice pattern. Signed-in identity details may be prefilled, but the
public form remains available to people who cannot sign in. Discord community
access is a distinct provider-colored card; it supplements rather than replaces
the durable contact channel.
Public support uses the SHD Contact Relay: a broad transmission form and a
compact destination guide. Two-to-six destinations use the shared
segmented-choice pattern. Signed-in identity details may be prefilled; people
who cannot sign in retain access to the public form. Discord community access
uses a distinct provider-colored card alongside the durable contact channel.
The Contact hero explicitly resets the global site-header height: semantic page
`header` elements must use content-driven height and spacing rather than inherit
the 76-pixel navigation-shell constraint. Its title, description, and Discord
The Contact hero resets the global site-header height: semantic page `header`
elements use content-driven height and spacing instead of the 76-pixel
navigation-shell constraint. Its title, description, and Discord
card align within a minimum 230-pixel desktop band before the form grid begins.
The Contact page itself remains content-height-driven so high-resolution browser
zoom does not multiply a viewport minimum into a large empty region above the
footer.
The application frame is a full-viewport vertical flex stack. The shared footer
uses automatic top margin, so it rests against the bottom edge on short routes
and follows normal document flow after longer content. Individual pages must
not add spacer heights solely to position the footer. The frame compensates for
The application frame uses a full-viewport vertical flex stack. The shared
footer uses automatic top margin. It rests against the bottom edge on short
routes and follows document flow after longer content. Do not add spacer heights
only to position the footer. The frame compensates for
`--shd-display-scale` when calculating its viewport minimum so high-resolution
browser zoom does not push a short-page footer below the visible edge.
@@ -66,19 +102,18 @@ from the orange links reserved for SHD navigation and legal resources. Support
and legal links form a compact horizontal rail that may wrap at intermediate
widths and becomes a two-column touch-friendly grid on narrow mobile screens.
The footer repeats the complete SHD brand lockup used by the global header:
double-ring SHD mark, white `SHD`, orange `.build`, and the restrained
`RESURGENCE COMMUNITY` descriptor. Do not substitute a plain text wordmark or
color the complete product name orange.
The footer repeats the complete Core Segments lockup used by the global header:
two-color emblem, Field Manual White `SHD`, Signal Orange `.build`, and the
`RESURGENCE DATA & TOOLS` descriptor. Do not substitute a plain text wordmark,
drop either emblem color, or color the complete product name orange.
### Community platform positioning
### Platform positioning
SHD.build is a full Division Resurgence community, not solely a loadout tool.
Primary product language and global navigation must represent five durable
channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms
remain appropriate inside build routes, but the global brand descriptor,
homepage metadata, hero, sign-in introduction, and footer describe the broader
community platform.
SHD.build provides Division Resurgence data and tools for players. Primary
product language and global navigation represent five durable channels: News,
Database, Builds, Guides, and Builder. Loadout-specific terms remain
appropriate inside build routes. The global brand descriptor, homepage
metadata, hero, sign-in introduction, and footer describe the broader toolset.
The homepage uses a community-channel grid to establish this information
architecture. Each channel has an operational code, plain-language purpose,
@@ -198,7 +233,11 @@ The canonical tokens live in `src/app.css` under `:root`.
| Page background | `--shd-bg` | `#0a0c0d` |
| Base surface | `--shd-surface` | `#111415` |
| Raised surface | `--shd-surface-raised` | `#141718` |
| Modeled-data surface | `--shd-model-surface` | `#0d1213` |
| Subordinate border | `--shd-border-soft` | `#272c2d` |
| Primary border | `--shd-border` | `#303536` |
| Modeled-data border | `--shd-model-border` | `#3b4648` |
| Interactive boundary | `--shd-border-strong` | `#424849` |
| Main text | `--shd-text` | `#ecebe5` |
| Muted text | `--shd-text-muted` | `#858b8d` |
| SHD action orange | `--shd-orange` | `#f6a821` |
@@ -206,15 +245,14 @@ The canonical tokens live in `src/app.css` under `:root`.
| Supporting copy floor | `--shd-type-supporting` | `14px` |
| Ordinary body floor | `--shd-type-body` | `16px` |
| Adaptive display scale | `--shd-display-scale` | `1` by default; stepped on high-resolution desktops |
| Monospaced data | `--shd-font-mono` | Cascadia Mono with system monospace fallbacks |
### High-resolution display scaling
The application uses restrained, stepped interface scaling when the effective
CSS viewport is substantially wider than a conventional desktop. At 2200px the
complete interface scales to 115%, at 2800px to 130%, and at 3400px to 150%.
This preserves the established relationship between typography, controls,
tables, spacing, hit targets, and fixed-position surfaces instead of enlarging
text alone.
The application uses restrained stepped interface scaling on wide CSS
viewports. At 2200px, the interface scales to 115%; at 2800px, to 130%; and at
3400px, to 150%. This maintains the established relationship among typography,
controls, tables, spacing, hit targets, and fixed-position surfaces.
Use the shared `--shd-display-scale` token and the root-level `zoom` treatment;
do not add module-specific 4K font-size overrides. Responsive layouts below
@@ -231,7 +269,7 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
## Typography
SHD.build self-hosts two intentional type voices through Fontsource packages:
SHD.build self-hosts two type voices through Fontsource packages:
- **Barlow** is the operational family for display headings, navigation,
controls, data, labels, metadata, and general interface copy. Use the shared
@@ -241,10 +279,9 @@ SHD.build self-hosts two intentional type voices through Fontsource packages:
`--shd-font-editorial`; do not apply it to controls, tables, metrics, or
telemetry labels.
Only the used Barlow weights (400, 600, 700, 800, and 900) and Source Serif 4
weights (400 and 600) are loaded. Both families are self-hosted, preserve
fallback stacks, and render immediately without a third-party font request.
The two-voice rule remains semantic: Barlow operates the system; Source Serif 4
Load Barlow weights 400, 600, 700, 800, and 900, plus Source Serif 4 weights
400 and 600. Both families are self-hosted, keep fallback stacks, and render
without a third-party font request. Barlow operates the system; Source Serif 4
explains it.
- Page and module headings are uppercase, heavy, and tightly spaced.
@@ -263,11 +300,12 @@ explains it.
### Global mobile navigation
At viewports up to 900px, replace the hidden desktop link rail and account
controls with a clearly labeled square `MENU` control in the shared site
controls with a labeled square `MENU` control in the shared site
header. Activating it opens a right-side SHD navigation panel over a dismissible
backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My
Profile, and ISAC when authorized; authenticated agents also receive identity
state and Sign Out inside the panel.
backdrop. The panel includes Home, each enabled public destination, Sign In or
My Profile, and ISAC when authorized. Authenticated agents also receive identity
state and Sign Out inside the panel. Section availability controls both desktop
and mobile navigation.
Use numbered full-width navigation rows, explicit active-route text styling,
orange action indicators, and minimum 44px interaction targets. Opening moves
@@ -287,13 +325,60 @@ squeezed beside long module headings on mobile.
### Public pages
- Maximum content width: approximately 1400–1640px.
- Larger headings and more generous vertical rhythm are appropriate.
- Public pages may use larger headings and more generous vertical rhythm.
- Preserve the grid background and orange accents.
- Catalogue and editorial pages use `catalogue-hero` for an angular introductory
panel and may pair it with a compact operational control bar.
- Community build cards retain the colored top rule, abstract loadout artwork,
concise tags, agent attribution, and a full-width terminal action.
### Prelaunch network shell
Use the standalone prelaunch shell for `/coming-soon`. It replaces the global
application shell while the public launch gate is active, so visitors see one
clear deployment message instead of navigation into gated sections. Keep the
SHD.build mark, a visible network-state block, one primary access action, the
Discord community action, the deployment manifest, and legal links. Signed-out
visitors receive `AGENT SIGN IN`; an authenticated operator previewing the page
receives `ENTER FULL SITE`.
The desktop composition pairs the launch statement with a technical network
visual. Use the shared grid background, square borders, restrained orange
emphasis, and telemetry labels. Orange identifies the primary action and active
signal paths. Green may reinforce a staged or available state; red may reinforce
an unavailable channel. Every state also needs a text label.
The deployment manifest lists News, Database, Builds, Guides, and Builder with
plain-language descriptions. A visitor who follows a disabled-section URL sees
the affected section name in a status notice. The page must state whether new
agent registration is open without implying that sign-in for existing accounts
has closed. The manifest uses the saved section controls as its source of truth
and marks unavailable sections in text. An authenticated preview follows the
same availability states.
At widths below 900px, stack the launch statement above the network visual and
collapse the manifest to one column as space requires. Preserve 44px action
targets and keep status text within the viewport. Reduce the radar sweep and
other ambient motion under `prefers-reduced-motion`; retain the static rings,
nodes, and labels.
ISAC Site Controls use three connected command panels: Launch Gate, Account
Creation, and Public Sections. Use `SegmentedChoice` for each finite state.
Display the pending-change count outside the panels and require a confirmation
modal that lists every changed setting as previous state, arrow, and proposed
state. A successful save closes the modal and refreshes the network-state
summary. Keep a `PREVIEW PAGE` link in the command toolbar so authenticated
operators can inspect the promotional page without ending their session.
Operators need text for enabled, disabled, coming-soon, and public states; color
supports those labels.
The Launch Gate controls signed-out entry to the application. Public Section
controls apply to everyone. When an operator disables News, Database, Builds,
Guides, or Builder, remove that destination from global navigation for signed-in
and signed-out users and block its route family on the server. The promotional
page identifies a disabled destination when a user follows an old bookmark or
direct URL. Do not present authentication as a way around a disabled section.
### Agent and authentication surfaces
- Account pages should read as an agent dossier: identity and access at the top,
@@ -375,7 +460,8 @@ squeezed beside long module headings on mobile.
white Apple mark on black, Discord uses its unmodified white symbol on
Blurple, and Steam uses its white linked-circle mark on Steam navy.
- Providers that are not configured are disabled, desaturated, and marked
`PLANNED`; they must not navigate, submit, or visually imply availability.
`PLANNED`; they must not navigate, submit, or imply availability through
styling.
- When each provider is implemented, replace its placeholder with the
provider-rendered control or current pre-approved sign-in artwork where the
provider requires it. In particular, use Google Identity Services or its
@@ -389,6 +475,11 @@ squeezed beside long module headings on mobile.
- Authentication uses one unified access screen rather than separate sign-in and
registration destinations. Verified email and first-time OAuth identities may
create an account; returning identities sign into the matching account.
- With account creation disabled, keep returning-agent methods available, hide
the password `CREATE ACCOUNT` mode, and show a concise registration-state
notice. Provider and email controls must explain that unknown identities
cannot create an account without suggesting that existing identities lost
access.
- Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch
inside the fallback panel. Account creation separates the public display name
from the private ASCII login username.
@@ -413,6 +504,12 @@ squeezed beside long module headings on mobile.
- The administrative system is named ISAC. Separate navigation into explicit
operational domains such as User Operations and Data Management; visibility
must reflect server-provided capabilities.
- In the ISAC rail, operational-domain headers are compact disclosure bars, not
module destinations. Give them a stronger surface, bold uppercase text, a
count, and a short leading status rail that turns orange while expanded.
Module destinations sit on the darker recessed surface below, use sentence
case and an additional left inset, and reserve the full orange rail for the
current module. Preserve this hierarchy when the rail becomes a mobile grid.
- ISAC module navigation uses complete, game-facing names such as `Weapons`,
`Talents`, and `OS Protocols`. Do not expose internal abbreviations such as
`WPN`, `TLNT`, or `OSPR`. Canonical game icons may supplement visible labels
@@ -444,7 +541,17 @@ squeezed beside long module headings on mobile.
Each provides context-appropriate filters and namespaced pagination controls
so changing one history page does not reset the others.
- Each module starts with `module-toolbar`, followed by `module-kpis`.
- Editors use `dashboard-editor` and appear before the affected table.
- Editors use `dashboard-editor`. New-record editors appear before the affected
table. Editing an existing record expands the same editor directly beneath
its selected row, which gains an orange selection rail and an explicit
expanded relationship for assistive technology. Inline editors stay aligned
to the visible registry viewport when the table scrolls horizontally. A
short clipped reveal confirms the expansion; reduced-motion preferences
remove that animation.
- Optional record descriptions use a full-width narrative field beneath the
identity controls. Preserve the same full-width placement in Oversight
before/after previews so reviewers can compare meaning without reading raw
proposal data.
- Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`.
- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably.
- User dossiers pair access controls and moderation in a two-column console,
@@ -483,7 +590,11 @@ squeezed beside long module headings on mobile.
restrained ten-second interval. Metrics must not expose environment values,
credentials, full filesystem paths, or other deployment secrets. The
`metrics.view` permission is independent of mutation access and is included
in the read-only Auditor role.
in the read-only Auditor role. External-service capacity reuses the same
metric panel: show distinct provider pools, the last authoritative check,
configured alert thresholds, and explicit `OK`, `WARNING`, `CRITICAL`, or
unavailable text. Green, orange, and red reinforce these states but never
replace their labels.
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
and plain-language retention guidance. Soft-deleted accounts retain authored
content under a deleted-agent identity while releasing credentials, usernames,
@@ -554,18 +665,17 @@ Do not communicate state by color alone; include a label, icon, or pattern.
5. Use the shared tokens instead of introducing near-duplicate colors.
6. Verify keyboard focus, pending behavior, empty states, mobile layout, and archived data behavior.
If a new interface needs a genuinely new pattern, document it here when introducing it.
Document a new interface pattern here when you introduce it.
## Design-system governance
Significant visual and interaction changes must update this document and the
shared implementation in the same change. A change is significant when it
introduces or materially revises a reusable token, component pattern, layout,
responsive rule, accessibility behavior, motion behavior, or state convention.
Update this document and the shared implementation with each significant visual
or interaction change. A change qualifies when it introduces or revises a
reusable token, component pattern, layout, responsive rule, accessibility
behavior, motion behavior, or state convention.
Small alignment corrections and isolated visual bug fixes do not require a
documentation change unless they establish a rule that other interfaces should
follow.
Small alignment corrections and isolated visual bug fixes need no documentation
change unless they establish a reusable rule.
Repository agents and contributors should follow the maintenance checklist in
`AGENTS.md`.
@@ -609,10 +719,13 @@ back to its weapon rather than opening the shared-talent editor.
Large relationship sets use the searchable assignment control instead of
checkbox walls. It keeps assigned records visible as removable chips, filters
available records by name, renders at most thirty results at once, and exposes
counts for both assignments and matches. Mutually exclusive pools pass their
assigned IDs to one another as exclusions so invalid combinations cannot be
selected. Keyboard focus, pressed state, empty results, and overflow remain
available records by identity, renders at most thirty results at once, and
exposes counts for both assignments and matches. When records can share a name,
show the identity-defining qualifier below it in both selected chips and search
results. Attribute assignments use `UNIT · %`, `UNIT · s`, or `UNIT · FLAT`;
search and accessible names include that unit. Mutually exclusive pools pass
their assigned IDs to one another as exclusions so invalid combinations cannot
be selected. Keyboard focus, pressed state, empty results, and overflow remain
explicit.
Where a relationship has a global slot invariant, filter each assignment
control at the data boundary instead of presenting cross-slot choices. Weapon
@@ -745,20 +858,50 @@ never silently describe only the visible page. On narrow screens, retain
previous, next, current page, and page-size controls before optional page
numbers.
The row-count presets remain available even when the current result fits on one
page. Omit only the page navigation when there is no second page. Do not render
disabled pagination controls merely to preserve the rail's shape.
Registries with a meaningful name, title, or agent-identity column place the
shared server-side search inside this pagination rail. Submission resets the
page number while preserving the selected KPI filter and row-count preset;
page navigation preserves the query. Provide an explicit clear action whenever
a query is active. Search applies to the complete filtered registry before
pagination, including visible pending proposals, and never only to the current
page. Specialized oversight and history searches may remain in their richer
filter consoles when they search evidence beyond a single identity column.
shared server-side search inside this pagination rail. Results update after a
short typing debounce, so do not place a redundant Search button beside the
field or label the field as live search. Show `SEARCHING…` only while navigation
is pending, and provide an explicit clear action whenever a query is active.
Search resets the page number while preserving the selected KPI filter and
row-count preset; page navigation preserves the query. Search applies to
the complete filtered registry before pagination, including visible pending
proposals, and never only to the current page. Specialized oversight and
history searches may remain in their richer filter consoles when they search
evidence beyond a single identity column.
At intermediate widths the search spans the pagination rail above its range
and navigation controls. At mobile widths all groups stack, the input retains a
usable width, and search, clear, previous, and next remain explicit keyboard
and touch targets.
### Sortable registry columns
Every meaningful data column in an ISAC table uses the shared sortable-column
header. The first activation sorts a new column in its most useful default
direction; later activations reverse the direction. Place a neutral
bidirectional indicator immediately before each inactive column label and an
orange directional indicator immediately before the active column label.
Action columns do not sort.
Sorting applies on the server before pagination. Store the selected column and
direction in the URL, preserve active search, filters, and row-count settings,
and return to page one when the sort changes. Server loaders must map public
sort keys to fixed, allowlisted SQL expressions or in-memory comparators. Never
interpolate a query-string value into SQL. Composite columns use a documented,
stable primary value and deterministic tie-breaker. Pending contribution rows
remain ahead of published records unless the registry explicitly defines a
different moderation order.
Sortable headers must expose column-header semantics and `aria-sort`, remain
keyboard operable, and show visible hover, focus, active, and selected states.
At narrow widths, retain the table's existing horizontal overflow behavior so
labels and sort state do not collapse into ambiguous controls.
### Registry metric filters
The KPI row above an ISAC registry is an operational filter surface, not passive
@@ -771,9 +914,24 @@ Filtering is server-backed and occurs before pagination. Filter links preserve
compatible search and page-size parameters, reset the page number, remain
bookmarkable, and expose the selected state with both orange emphasis and
`aria-current`. Cards require pointer, hover, focus-visible, pressed, and
textually identifiable active states. Counts describe the full search scope,
not merely the visible page. Do not use aggregate facts that cannot filter the
affected registry—replace them with actionable subsets.
textually identifiable active states. Every card explicitly says `FILTER →`
or `SELECTED`; do not rely on KPI-card styling alone to communicate that the
metrics are controls. Counts describe the full search scope,
not merely the visible page. Replace aggregate facts that cannot filter the
affected registry with actionable subsets.
### Registry row interaction
Desktop registry rows are passive data containers unless the entire row opens
a detail view. Do not apply a whole-row hover surface when only
the trailing Edit or View action is interactive. Render that action as a
compact bordered control with hover, focus-visible, and pressed states so its
interaction boundary is unmistakable.
On mobile, a row may become a full-width detail target when the route provides
an intentional mobile detail interaction. In that case retain the explicit
`TAP TO VIEW` or `TAP TO REVIEW` text, full-row focus treatment, and minimum
touch target; do not infer full-row behavior from desktop hover styling.
### Image uploads
@@ -784,7 +942,7 @@ and normalization checks remain authoritative.
### Moderated data states
ISAC must clearly distinguish direct editing, contributor proposals, and read-only
ISAC must distinguish direct editing, contributor proposals, and read-only
access. Use a compact access banner above the module rather than changing the
registry layout. Pending proposal counts appear as orange numeric indicators in
data-navigation items. Proposal lifecycle states use text as well as color:
@@ -800,19 +958,24 @@ changes to orange, explains that saves enter moderation, and offers a
`RETURN TO READ-ONLY` toggle. Disabling is immediate and reversible and does not
withdraw existing proposals. Use text for every mode state; color alone is
insufficient. At narrow widths, stack the explanation and full-width toggle.
Show contributor access banners only on Data Management modules. Show the
`DATA MODE` status on those modules and the contributor-guidelines page. Omit
data-mode controls and language from User Operations, System Oversight, and
System Configuration because their permissions govern access independently.
Read-only mode is an inspection surface: hide add controls, row edit actions,
mutation forms, and editors opened through URL state. Do not merely dim or
disable them. Registry navigation, KPI filters, pagination, search, evidence,
and other non-mutating inspection controls remain visible and interactive.
The ISAC command-status cluster includes an explicit `DATA MODE` cell alongside
system, access, and environment. It reports the highest effective data authority
in this order: `ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then
`READ-ONLY`. Use a neutral treatment for read-only, orange for contributor,
blue for Oversight authority, and green for direct-management authority. The
label and value are mandatory because color is supplemental. At medium widths
the four cells become a two-column grid; on narrow screens the cluster spans
the command header width.
The ISAC command-status cluster includes system state, access state, the themed
ISAC node identifier, the CI build number, and an explicit `DATA MODE` cell.
The data-mode cell reports the highest effective authority in this order:
`ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then `READ-ONLY`. Use a
neutral treatment for read-only, orange for contributor, blue for Oversight
authority, and green for direct-management authority. Every cell requires a
label and value because color provides supplemental information. At medium
widths, arrange the cells in a two-column grid and let the data-mode cell span
both columns. On narrow screens, the cluster spans the command header width.
### Oversight proposal corrections
@@ -931,11 +1094,12 @@ responsive editor layouts.
Large many-to-many selections use the shared searchable assignment control,
including weapon talent pools, gear talent compatibility, and compatible brand
sets. The control keeps assigned records visible as removable chips, filters
available records by name, limits the rendered result set, and submits canonical
hidden inputs. Configure its subject labels and empty-state copy for the entity
being assigned rather than embedding talent-specific language in the shared
component. Pending contributor records retain their orange treatment and named
proposer in both search results and dependency workflows.
available records by their displayed identity, limits the rendered result set,
and submits canonical hidden inputs. Configure its subject labels, qualifiers,
and empty-state copy for the entity being assigned rather than embedding
talent-specific language in the shared component. Pending contributor records
retain their orange treatment and named proposer in both search results and
dependency workflows.
Audit History may use a five-column KPI filter rail when all durable operational
domains need first-class visibility. It retains the standard compact KPI height
@@ -1037,8 +1201,15 @@ must use their named component classes rather than the global application
`VIEW DETAILS` opens the standard in-page modal rather than navigating
immediately. The modal repeats sender, source, category, semantic tone, time,
full message body, read control, and optional related-page destination. This
keeps review context in the inbox and makes outbound navigation deliberate.
full message body, read control, and related-page destinations. Keep the sender
header and utility footer fixed while the message and action area scrolls.
Group destination actions beneath an `AVAILABLE ACTIONS` label in a regular
grid; reserve the footer for message state and closing the modal. Opening the
modal moves focus to its close control. Trap keyboard focus inside it, support
Escape and backdrop dismissal, and return focus to the originating `VIEW
DETAILS` control after closing. Inbox cards show up to four lines of body copy
at reading size before truncation. Place the per-message read control beside the
timestamp so message state remains separate from workflow actions.
ISAC message content uses the structured system-transmission body. A compact
blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting
@@ -1048,20 +1219,20 @@ same hierarchy at reading size. Community messages remain conversational body
copy; do not apply the system-transmission frame to agent comments.
Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
message when preview copy is truncated to two lines. A separate bordered orange
response action navigates directly to the known workflow and uses specific
language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or
`VIEW BUILD & REPLY`; never substitute a generic destination label when the
notification type is known. The same response label appears in the detail
modal. On mobile, response actions become full-width 44-pixel targets.
message when its four-line preview truncates longer copy. A separate bordered
orange response action opens the known workflow and uses specific language such
as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or `VIEW BUILD & REPLY`; never
substitute a generic destination label when the notification type is known.
Lay out these controls in an auto-fitting grid so each label remains intact.
The detail modal uses the same response labels in a three-column grid where
space permits. On mobile, response actions become full-width 44-pixel targets.
Inbox filtering uses additive segmented selectors derived from the OS Protocol
quality control. Channels and message states are independent checkbox-backed
segments, all selected by default, and each selection change applies
immediately without a submit button. Selected segments use the orange lower
rail and filled square indicator; deselected segments remain readable and
available. The channel rail collapses from six to three and then two columns as
space narrows, with minimum 44-pixel mobile targets.
Inbox query controls use two compact radio-backed segmented selectors. `SHOW`
switches between all, unread, and read messages. `SORT` offers newest, oldest,
and unread-first order. Give both selectors the same compact width. Each
selection applies without a submit button. Live search matches message titles,
bodies, and sources across the paginated result set. At narrow widths, the
selectors stack and retain 44-pixel targets.
Use the shared `SegmentedChoice` component for every static, scannable ISAC
choice set containing two through six options. Radio mode represents one
@@ -1089,22 +1260,20 @@ unfocusable and unchanged.
`SegmentedChoice` supports two restrained density variants. Icon-only segments
may omit visible text when a canonical, unambiguous game icon exists; they must
retain an accessible label and hover title. Text-only segments may omit the
default square indicator when the selected background, orange text, and lower
rail already communicate state clearly. Do not remove both visible text and an
icon from the same option.
retain an accessible label and hover title. Do not render generic dots, squares,
checkbox facsimiles, or radio facsimiles inside segments. The selected
background, orange text, and lower rail communicate state. Keep visible text or
an icon in every option.
### Skill Chip data console
Skill Chip editing follows the dense ISAC staged-definition pattern: identity
and icon first, the 2-piece numeric bonus second, mutually exclusive primary
and secondary roll pools next, and the 3-piece talent last. Superior blue and
High-end yellow communicate the only supported qualities; orange remains
reserved for actions and current interactive state. A four-step segmented
preview exposes the linear zero-to-three High-end interpolation without storing
duplicate values. At narrow widths the roll pools and talent columns stack,
preview segments become a two-by-two grid, and interactive targets remain at
least 44 pixels tall.
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
pools, then 3-piece talent. Superior blue and High-end yellow identify the
supported qualities; reserve orange for actions and the current interactive
state. A four-step segmented preview shows the linear zero-to-three High-end
interpolation without duplicate values. At narrow widths, stack roll-pool and
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
### ISAC record identity controls
@@ -1117,12 +1286,12 @@ drop-zone to fill spare identity-row space.
Icon-enabled consecutive-entry editors pass a successful-add result token into
the shared upload control. Advancing that token clears the native file input,
temporary preview URL, filename, drag/paste feedback, and validation state.
This reset occurs only after a validated add succeeds; failed saves preserve
the selected image, and successful edits close the editor normally.
Reset only after a validated add succeeds. Failed saves preserve the selected
image; successful edits close the editor.
The primary `name` field in every ISAC data editor is a header-level input:
46 pixels tall with larger, heavier text. This creates a consistent first
reading point for long registries while keeping secondary metadata compact.
The primary `name` field in each ISAC data editor is a 46-pixel header-level
input with larger, heavier text. It gives long registries a consistent first
reading point while keeping secondary metadata compact.
`SearchableAssignment` is the standard relationship picker for long attribute,
talent, trigger, and compatibility lists. Multiple mode manages additive sets;
@@ -1150,8 +1319,8 @@ grid, both selectors use at least 44-pixel targets, and changing slot clears
incompatible Talent relationships exactly as the former selector did. Gear
slot segments use icon-only controls with accessible slot names; registry labels
retain icon and text for rapid table scanning. Tier segments use the compact
game notation `T0`, `T1`, and `T2`, omit the redundant square indicator, and
rely on the selected rail and color; do not expand these to “Tier 0” inside
game notation `T0`, `T1`, and `T2`, and rely on the selected rail and color; do
not expand these to “Tier 0” inside
dense segmented controls. On desktop, icon-only Gear slot segments are square
32-pixel cells so their classification rail stays compact and the adjacent
record-name input receives the dominant width. Increase those cells to square
@@ -1278,9 +1447,14 @@ Chips. Attributes are implementation vocabulary resolved into the facts and
effects shown on those records; do not promote Attributes to a public
top-level category.
Each index category is a fully interactive square registry card with a live
approved-record count, concise scope statement, visible hover/focus state, and
pressed feedback. A category page uses the shared compact category rail,
Each index category is a fully interactive square registry card with its full
player-facing category name as the first and strongest element. Record count
and availability are supporting metadata in the footer; operational states
such as “active registry” or “intake” must never compete with the category
identity. Use the larger display-weight category name with a short orange
identity rail; render any adjacent count in muted telemetry styling. Cards
retain a concise scope statement, visible hover/focus state, and pressed
feedback. A category page uses the shared compact category rail,
live name search, pagination, and a responsive card grid. Record cards use the
same hierarchy:
@@ -1315,9 +1489,22 @@ narrow layouts use one. The eight-category rail reduces from eight to four to
two columns. Preserve keyboard focus, pressed feedback, reduced-motion
behavior, and readable textual state in every layout.
Category-specific discovery uses the shared tactical filter matrix. Filters
apply immediately, remain encoded in the URL, reset pagination, and compose
with name search. Multiple selections inside one facet are inclusive (`OR`);
Category-specific discovery uses the shared `DatabaseDiscoveryConsole`. It
joins the tactical filter matrix, live registry search, result range,
pagination, and page-size controls inside one connected instrument; do not
stack separate bordered filter and pagination bars. Categories without facets
retain the same console with search and pagination alone.
The matrix is a disclosure inside that console. It starts expanded on desktop
and collapsed on viewports at or below 700px when no filters are active. Active
filters make the initial state expanded. Its header always exposes the active
filter count and an explicit `SHOW FILTERS` or `HIDE FILTERS` action, while
search and pagination remain visible when the matrix is closed. Preserve the
user's manual disclosure choice in session storage rather than carrying it
between unrelated browser sessions.
Filters apply immediately, remain encoded in the URL, reset pagination, and
compose with name search. Multiple selections inside one facet are inclusive (`OR`);
active facet groups combine restrictively (`AND`). For example, selecting two
Brands and four Talents returns Gear matching any selected Brand and any
selected Talent. Never implement multi-select facets as an implicit match-all
@@ -1418,6 +1605,9 @@ proposing a canonical item. Upload fields must state that an icon may be
contributed later and show the shared `/missing-item-icon.svg` artwork when no
upload exists. Registries, relationship rails, and builder selection controls
use the same placeholder instead of initials, empty boxes, or ad hoc glyphs.
Application code references that asset through the shared
`MISSING_ITEM_ICON_URL` constant and renders it through `ItemIcon`; do not
duplicate its path or introduce module-specific missing-icon artwork.
Treat the placeholder as an explicit incomplete-data state: keep its subdued
SHD styling and useful alternative text where the surrounding item name is not
@@ -1510,9 +1700,11 @@ choices are remembered locally for that browser. Searching the rail temporarily
reveals matching modules across all authorized categories without changing the
remembered category state.
Use the compact command-status strip for persistent system, access, environment,
and data-mode context. These values remain textual and must not rely on color
alone. Registry search remains available for a single page of results, while
Use the compact command-status strip for persistent system, access, ISAC node,
and CI build number context. Add the data-mode cell only in the Data Management
context defined above. Keep each label above its value so the visible cells
share one alignment. Text must communicate every state without relying on
color. Registry search remains available for a single page of results, while
page navigation and row-count controls appear only when the result set spans
multiple pages. This keeps expert lookup fast without presenting inactive
controls to contributors and first-time Oversight operators.
@@ -1522,6 +1714,31 @@ navigation regions. Search fields have visible or accessible labels, keyboard
focus uses Signal Orange, and collapsed state must remain understandable
without motion.
### Managed image library
Reusable uploaded visuals belong in the ISAC Image Library under System
Configuration. Present assets as a dense responsive registry of preview cards:
the artwork is primary, while name, assignment category, tags, dimensions, and
file size remain compact operational metadata. A checkerboard preview surface
may make transparency legible without changing the asset.
Image intake uses the shared picker, drop-zone, and clipboard control. The
`Normalize as icon` option trims transparent outer margins and standardizes the
canvas. When the source has no usable transparency, the server requests
preview-resolution background removal before local normalization and reports
configuration, credit, rate-limit, foreground-detection, or network failures
with a specific recovery. Its dependent `Force white` option converts visible pixels to the
canonical neutral icon color while preserving alpha. Both controls state their
processing consequence in text, expose checked and disabled states, and remain
keyboard accessible. Metadata edits preserve the stable asset identity, close
after a successful save, and use the shared toast and editor lifecycle.
Use controlled assignment categories for broad placement and free-form,
normalized tags for discovery. Names are unique and human-readable. Search
covers names, categories, and tags. On narrow screens, processing controls
stack vertically and cards reduce to one column so previews and actions retain
usable sizes.
### ISAC mutation consequence
Every writable game-data module states what Save will do before the operator
@@ -1538,4 +1755,173 @@ Pair the short state label with a complete consequence sentence. Contributors
must not have to infer this behavior from their role name, data-mode color, or a
success message shown after saving. Read-only operators do not see a mutation
statement because their mutation controls are absent. At narrow widths the
message becomes its own compact row above the module toolbar.
message becomes its own compact row above the module toolbar. A toolbar with
multiple actions must reserve the complete action group width in the inline
desktop strip so the consequence sentence never runs beneath a button.
### Public database index
The public Database is a lookup surface first and a contribution gateway
second. Use a compact angular command band rather than a cinematic display
hero: keep the archive identity, state how many registries are online, state the
record total, and present contribution as a bordered secondary action.
Place one discovery bar below the command band. Its label gives users two equal
paths: select a registry or search all data. Its search field searches across
all public registry names and useful
descriptive data such as Talent text, Damage Types, item slots, qualities, and
Brand bonus Attributes. Do not stack a separate registry-heading bar above or
below it. Results name both the matching record and its registry, then link into
that registry with the record name applied as its local search. Keep the search
and results dense, keyboard accessible, responsive, and URL-addressable.
Registry selection begins immediately after the shared discovery bar. Do not
place stewardship, status, promotional, or redundant instructional panels
between it and the registry grid. At wide desktop widths, eight registries form a
four-by-two matrix; the grid reduces to two columns and then one as available
space narrows. Registry cards lead with complete category names. Counts and
availability remain quiet footer metadata, and internal abbreviations or
workflow-centric states are not exposed as primary public labels.
### ISAC progression-tree composer
Use the Specialization workbench pattern for ordered game systems that depend
on hierarchy and spatial placement. Keep the registry, selected parent identity,
child navigation, readiness telemetry, and visual tree in one operational
surface. Editors open above the registry and follow the standard ISAC lifecycle.
Use the workbench for navigation and context.
The tree uses a stable 12-column track per row. Row and column are explicit
numeric fields so placement is keyboard operable, auditable, and portable;
never make drag-and-drop the only authoring method. On narrow screens, retain
the geometry in a horizontally scrollable canvas instead of squeezing nodes
until their names and state are unreadable. Controls outside the canvas stack
and maintain at least a 44-pixel touch target.
Each row header states its derived gate in plain language. Specialization gates
count all available Rank 1 and Rank 2 unlocks in preceding rows, including the
two immediately available ability nodes displayed in the first row. Do not
store or manually edit a second gate value that could diverge. Free nodes use
the orange identity rail plus the explicit `1 / 1 · FREE` state; pending,
archived, incomplete, and builder-ready states must remain textual as well as
colored.
Show allocation scope as first-class telemetry. A specialization unlocks once;
each focus receives its own full point allocation. Show the point total and
scope near the tree. A focus becomes builder-ready after it has three distinct
active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
and 3 as free tree nodes. Label slot 2 Combat Skills-only and exclude it from
the upgrade tree. Universal grenade and medical-kit actions are outside the
specialization ability set.
Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of
calculation-ready effects. It provides Attribute, Ability when applicable, and
Custom targets; a numeric value; and the shared bounded unit vocabulary. Keep
the target searchable, show all bounded type and unit choices with segmented
controls, and preserve explicit custom labels for mechanics that calculations
do not yet support. Oversight previews must render effect and rank summaries as
structured cards. Serialized JSON belongs only in the expandable raw-evidence
panel.
Exclusive child-record relationships use a searchable assignment block directly
after the record identity. The header names the parent type, states the
relationship and capacity in plain language, and shows current usage as `N / N`.
The registry repeats the assigned parent beside each child so exclusivity can be
audited without opening the editor. Records that require an exclusive parent
must always have a valid active assignment. Capacity rules are enforced on the
server and never communicated by color alone.
Rank cards present the complete rank description, structured effects, point
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma
components use the shared blue quality treatment and may be combined in any
positive whole-number quantities for Rank 2. Rank 3 uses the yellow High-end
Omega treatment and exactly one component. Color is supporting hierarchy;
quality names and quantities always remain visible.
### Conditional combat modeling
ISAC authors define a combat effect once per Specialization and attach it to
one or more tree-node ranks. Each rank attachment owns its hit trigger, stack
duration, stack limit, and Attribute modifiers. Keep the lifecycle rules in a
compact fact rail: trigger counters reset after a proc, each stack gain refreshes
the timer, and expiry clears the complete stack. The shared definition provides
one effect name and description to every attached rank.
Use `--shd-model-surface` and `--shd-model-border` for conditional mechanics,
calculation revisions, and field calibration. The blue information rail marks
modeled behavior. Orange remains reserved for selection and activation. Every
effect card names its trigger, duration, maximum stacks, and value per stack in
text so the mechanic remains clear without color.
The loadout builder gives each unlocked conditional effect three calculation
modes:
- `OFF` excludes the effect.
- `PEAK` applies its configured maximum stacks.
- `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size,
reload time, hit rate, firing activity, and encounter duration.
Users may override encounter assumptions on one effect without changing the
global combat profile. The effect card shows the applied stack state beside the
mode. On mobile, assumption fields and effect controls stack into full-width
44-pixel targets.
Combat result panels show normal, critical, headshot, and critical-headshot
values with burst, sustained, and encounter DPS. Conditional stack telemetry
lists the mode, average and peak stacks, ending stacks, buildup time, and
maintained state that produced the result. A result with missing equipment,
Attribute bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a
concrete remediation list. The interface withholds combat numbers until it has
the required inputs.
### Specialization loadout composer
The builder places Specialization focus selection after equipment
configuration. The focus identity combines Specialization artwork and the
primary Attribute mark, followed by three ability panels. Each ability panel
contains three fixed Skill Chip positions. A chip picker shows definitions
assigned to that ability and excludes definitions assigned elsewhere.
The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the
user buys every available Rank 1 and Rank 2 in preceding rows and enforces the
49-point limit. Cost telemetry lists Credits and components beside each rank.
Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and
combat assumptions. The builder accepts only the current complete structure.
Invalid local drafts are discarded; invalid server records are unavailable and
removed through a backed-up database migration.
### Calculation revision controls
Combat rules use immutable active and retired revisions. ISAC editors may
change draft revisions, ordered stages, and Attribute profiles. Activation
requires `calculation.publish`, an explicit review state, and a confirmation
that saved builds will recalculate. Successful activation closes the
confirmation surface and records the revision in the audit history.
Calibration cases pair a loadout snapshot with measured hit and timing values.
Ready cases require the four hit values, magazine time, and reload time.
Assisted suggestions create a draft revision for review and leave activation
to an authorized operator. Use monospaced text for raw loadout and prediction
snapshots; labels, findings, and errors stay in the standard operational
typeface.
### Image Library assignment state
An ISAC game-data editor registers each uploaded icon in the Image Library. The
library record and editor share one processed file. Its card uses `ASSIGNED
ICON`, names the owning entity type beside the category, and carries searchable
tags for `isac`, `game data`, the module, the entity name, and entity context
such as quality, focus, ability, or Talent category.
Operators may rename, recategorize, and add tags to the library record. Replacing
the icon from its game-data editor keeps that metadata and refreshes the context
tags and file facts. Removing the source icon removes its linked library record.
General library uploads retain the `ICON` or `IMAGE` state.
+32 -36
View File
@@ -1,25 +1,23 @@
# Weapon and gear stat level scaling
This document records the empirically observed rules for adjusting weapon and
gear stat ranges by item level. The rules apply independently to both endpoints
of a configured range.
Use these observed rules to adjust weapon and gear stat ranges by item level.
Apply each rule to both endpoints of a configured range.
These formulas are based on in-game observations rather than published game
mechanics. Preserve source observations when adding new samples and revise the
constants if a larger data set disproves them.
In-game observations, rather than published game mechanics, support these
formulas. Preserve source observations with new samples and revise constants
when a larger data set disproves them.
## Numeric representation and display
Stat ranges must retain their unrounded decimal values. The integer shown by the
game is consistent with truncation:
Store stat ranges as unrounded decimals. The game display truncates them:
```text
displayedValue = floor(rawValue)
```
Do not use `ceil`, and do not repeatedly level an already displayed integer.
Both approaches accumulate errors. A level-1 value displayed as `0` can still
have a positive fractional raw value that becomes visible at a higher level.
Avoid `ceil` and repeated leveling of displayed integers. Both accumulate
errors. A level-1 display of `0` can hide a positive fractional raw value that
appears at a higher level.
For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at
level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains
@@ -30,12 +28,12 @@ floor(0.7) = 0
floor(0.7 × 3.01) = 2
```
When only displayed integers are available, a prediction can be off by one.
That is expected information loss, not evidence for rounding upward.
Predictions from displayed integers can miss by one because truncation loses
information. That error does not indicate rounding upward.
## Shared progression model
The stable portion of both progressions is linear, not compounded:
The stable portion of both progressions is linear:
```text
rawStat(level) = hiddenBase × multiplier(level)
@@ -48,8 +46,8 @@ fixed `5%` of the hidden base:
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
```
Never calculate a new level as `previousDisplayedValue × 1.05`; that describes
compound growth and does not match the observations.
Do not calculate a new level as `previousDisplayedValue × 1.05`. That compound
growth model conflicts with the observations.
For a range `[minimum, maximum]`, calculate each endpoint separately:
@@ -58,7 +56,7 @@ scaledMinimum = hiddenMinimum × multiplier(level)
scaledMaximum = hiddenMaximum × multiplier(level)
```
Only truncate when producing a game-matching display value.
Truncate at the game-display boundary.
## Gear
@@ -68,9 +66,9 @@ The observed stable gear multiplier is:
gearMultiplier(level) = 0.91 + 0.05 × level
```
This rule is validated for levels 10 through 50 across Standard, Specialized,
and High-end samples. Quality changes the hidden stat ranges; the observed
late-level multiplier does not change with quality.
Samples across Standard, Specialized, and High-end qualities validate this rule
for levels 10 through 50. Quality changes hidden stat ranges; the observed
late-level multiplier remains the same.
Given hidden level-independent endpoints:
@@ -115,11 +113,10 @@ ENG = floor(0.7 × 3.01) = 2
### Early gear levels
Levels 1 through 9 show a quality-dependent transition and do not follow the
stable multiplier directly. Do not extrapolate that transition from a single
level-1-to-level-2 change. Until the early curves are sampled more completely,
store authoritative ranges for those levels or mark calculated results as
estimates.
Levels 1 through 9 show a quality-dependent transition outside the stable
multiplier. Do not extrapolate it from one level-1-to-level-2 change. Until
more samples define the early curves, store authoritative ranges or label
calculated results as estimates.
## Weapons
@@ -179,9 +176,9 @@ multiplier, then derive the combined pool bounds by multiplying each endpoint
by three. Redistribution changes the split between the three cores without
changing the selected item's total allocation.
The `1.14` offset is provisional and is currently validated only for the
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
without migrating rounded data.
The `1.14` offset remains provisional; observations only validate it from level
40 to 50. Retain enough decimal precision to revise it without migrating
rounded data.
### Weapon example
@@ -192,9 +189,9 @@ A weapon changed from `753 DMG` at level 40 to `873 DMG` at level 50:
floor(872.95) = 872
```
The displayed-source calculation misses by one because `753` is already
truncated. Applying the ratio to the unrounded source value produces the
observed `873`. This is why calculations must use stored decimal endpoints.
The calculation misses by one because `753` is truncated. The ratio applied to
the unrounded source value produces `873`, so calculations require stored
decimal endpoints.
The observed changes over those ten levels were linear:
@@ -207,11 +204,10 @@ ENG: 45 → 52 (about 0.7 per level)
### Early weapon levels
Do not apply the provisional late-level weapon offset to level 1. An Exotic
sample's displayed level-1 values and level-50 values fit a `2.91` endpoint
ratio for most stats, which is incompatible with simply extending the
level-40-to-level-50 offset backward. Weapons therefore also appear to have an
early transition or another quality-dependent input.
Do not apply the provisional late-level weapon offset at level 1. An Exotic
sample's level-1 and level-50 displays fit a `2.91` endpoint ratio for most
stats, which conflicts with extending the level-40-to-level-50 offset
backward. Weapons appear to have an early transition or quality-dependent input.
Until more intermediate samples are available:
+163 -37
View File
@@ -1,6 +1,6 @@
# SHD.build architecture
This project is intentionally being built in vertical slices.
The project is built in vertical slices.
## Planned stack
@@ -18,7 +18,8 @@ This project is intentionally being built in vertical slices.
- Editor: manage builder source data.
- Admin: manage users, roles, source data, and audit history.
Authorization must be checked on the server for every mutation. Hiding an admin link is not authorization.
Server code checks authorization for each mutation. Hiding an admin link does
not grant authorization.
Weapon and gear range calculations must preserve unrounded decimal endpoints
and follow the empirically validated progression rules in
@@ -26,22 +27,62 @@ and follow the empirically validated progression rules in
linear rather than compounded and game-matching integer values are truncated
only at the presentation boundary.
Local credentials are stored as Argon2id PHC strings with a unique random salt
per password. Current work factors follow OWASP's 19 MiB / 2 iteration /
single-lane minimum profile and can be increased over time; successful login
will eventually rehash credentials whose parameters are outdated.
Local credentials use Argon2id PHC strings with a unique random salt per
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
minimum profile. Successful login rehashes credentials with outdated parameters.
## Database runtime
The application uses Turso Database in-process through its native Node package.
It opens a local SQLite-compatible database file and does not use Turso Cloud,
libSQL HTTP, remote replicas, auth tokens, or synchronization.
It opens a local SQLite-compatible database file. The application does not use
Turso Cloud, libSQL HTTP, remote replicas, auth tokens, or synchronization.
The database defaults to `./data/shd-build.db`. Production deployments must
provide a persistent writable filesystem and can override the location with
`TDR_DATABASE_PATH`. This rules out ephemeral serverless filesystems unless a
persistent volume is attached.
## Site launch controls
The singleton `site_launch_settings` row stores the public launch gate, account
creation state, and availability for News, Database, Builds, Guides, and
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
`system_configuration / site_access.updated` with the operator, target row, and
complete before/after snapshots.
The request hook applies the launch gate to anonymous traffic and section
availability to every request:
- With Coming Soon enabled, anonymous `GET` and `HEAD` requests redirect to
`/coming-soon`. Anonymous mutations outside exempt routes receive a JSON 404.
- Authenticated agents bypass the Coming Soon gate and enter the application.
- A disabled public section blocks its route family for every user, including
authenticated agents: `/news`, `/database`, `/builds`, `/guides`, or
`/builder`. `GET` and `HEAD` requests redirect to `/coming-soon` with the
unavailable section identified. Mutations receive a JSON 404.
- Authentication callbacks, sign-in, contact, legal pages, health endpoints,
scheduler endpoints, application assets, and the prelaunch page remain
reachable without a session.
The global navigation omits disabled sections for every user. Server checks
remain authoritative for direct URLs and mutations. An authenticated operator
may open `/coming-soon` to preview the promotional page, but the preview does
not override section availability.
Account creation uses a separate control. Closing registration blocks password
registration, a magic-link request for an unknown email, and Discord OAuth for
an unknown Discord identity. Existing password, email, and Discord identities
can still sign in. The sign-in page hides the password registration form and
explains registration closure. This guard runs on the server, so a crafted form
submission or OAuth callback cannot create an account while registration is
closed.
## Increment plan
1. Product shell and anonymous local-draft builder.
@@ -92,13 +133,12 @@ not locked by another process.
## Development database maintenance
The development database remains valuable persistent data, but it is no longer
served to outside contributors and does not require a low-downtime migration
window. Stop only 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 preserve the verified backup if maintenance fails.
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.
Production and staging remain shared persistent environments and retain the
deployment, backup, synchronization, and maintenance safeguards documented in
@@ -120,13 +160,14 @@ mutation rather than deletion or modification of the original event:
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 means newer work
exists and blocks automatic compensation.
after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
work and blocks automatic compensation.
5. Referenced roles, permissions, and relationships are revalidated before
restoration. Restoring administrator authority additionally 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.
7. A reverted event cannot be reverted again.
# Moderated game-data contributions
@@ -156,8 +197,6 @@ assignment, so it applies consistently to existing users and every account
creation method. Registered agents can inspect live source data and submit
moderated proposals, but cannot directly mutate canonical records, review the
queue, or access unrelated ISAC operations without additional permissions.
7. A successfully reverted event cannot be reverted again.
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
@@ -184,6 +223,15 @@ and historical analysis should eventually use a dedicated external telemetry
system. This surface deliberately 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
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.
# Weapon-owned exotic talents
Standard weapon talent pools remain globally reusable and classified by Talent
@@ -256,7 +304,7 @@ 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, while T1 and T2 gear have two.
T0 gear has one Gear Mod slot; T1 and T2 gear have two.
Canonical data is stored in `gear_mods`, `gear_mod_quality_values`,
`gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`.
@@ -274,37 +322,115 @@ 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 separate pools.
Every active Skill Chip definition belongs exclusively to one active
specialization ability through `skill_chip_sets.specialization_ability_id`.
An ability has capacity for exactly three active definitions. The save and
restore actions enforce that capacity, and the foreign key is required for
every 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.
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
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores only endpoints
and previews all four states, preventing duplicated tier data from diverging.
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging.
Skill Chip mutations, owned relationships, and safe icon metadata are captured
in the `game_data` audit stream.
# Specialization combat effects and loadout state
`specialization_combat_effects` stores reusable effect identity and description
within one Specialization. A node rank attaches that identity through
`specialization_tree_rank_combat_effects` and supplies the trigger count,
trigger window, duration, stack limit, and stack gain. The first supported
lifecycle uses an enemy-hit counter that resets after a proc. Each stack gain
refreshes one shared duration, and expiry clears all stacks.
`specialization_tree_rank_combat_modifiers` maps each attachment to one or more
Attributes with a value per stack. This split lets ranks share effect language
while changing their trigger and values. Specialization mutations capture the
attachment and modifier sets in the `game_data` audit event. Contributors can
submit effect definitions and node attachments through the existing
Specialization proposal workflow.
Loadout schema version 2 stores one focus, the selected rank for each tree node,
nine Skill Chips, conditional-effect modes, and encounter assumptions. Each of
the three focus abilities owns three chip positions. Runtime readers accept
only the exact version 2 structure. Local drafts, saved builds, shared builds,
and calibration snapshots reject older or partial structures instead of
repairing them. Database migration removes obsolete snapshots after the
required environment backup.
# Versioned combat calculations
`calculation_revisions` supplies one active rule set. Draft revisions can
change, while active and retired revisions stay immutable.
`calculation_stages` stores the ordered typed pipeline. The base-damage stage
holds the first position, and the firing-cycle stage holds the last.
`attribute_calculation_profiles` maps an Attribute to a stage with its weight,
optional caps, curve, and weapon-category scope.
The shared combat engine consumes weapon facts, accumulated Attributes,
calculation profiles, and conditional effects. It produces four hit values,
burst DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry.
Encounter mode walks the expected shot timeline from RPM, magazine size,
reload time, hit rate, firing activity, and encounter duration. Peak mode
applies maximum stacks, and Off mode excludes the effect.
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.
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
hit values, timing values, optional encounter DPS, and the model prediction.
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 an operator to review.
Rule activation requires `calculation.publish` and an explicit 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,
calibration cases, and Specialization combat-effect tables in dependency order.
# Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork through the
polymorphic `game_data_icons` metadata table. Original uploads are never
retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and
AVIF sources, rejects active or externally referenced SVG content, and uses
Sharp’s asynchronous native worker pool to rasterize and normalize every
accepted icon to a transparent 256 × 256 WebP.
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
through the polymorphic `game_data_icons` metadata table. The shared server
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
or externally referenced SVG content, and uses Sharp’s native worker pool to
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory and are
served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
cache headers and MIME sniffing disabled. ISAC save actions process files
before mutation, apply upload or removal after record validation, and include
only safe icon metadata—not image bytes—in their audit snapshots.
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, allowing values and artwork to be contributed in
separate dependency-safe changes. Missing artwork is represented at render
time by the static `/missing-item-icon.svg` asset through the shared
`ItemIcon` component; the placeholder is never inserted into
`game_data_icons`. A later edit or proposal can attach the real icon without
recreating or replacing the canonical item.
`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component. The placeholder does
not enter `game_data_icons`. A later edit or proposal can attach the real icon
without recreating the canonical item.
`source_entity_type` and `source_entity_id` link each `game_data_icons` row to a
`managed_images` record. Both records share one storage key and one WebP file.
The managed record adds the searchable library name, category, tags, creator,
dimensions, and assignment state. Game-data icon saves upsert this record, keep
operator-edited metadata, and merge current entity-context tags. Removing a
source icon removes the linked library record and shared file.
The additive database migration registers older game-data icons and selects an
administrator or the earliest active account as the migration actor. ISAC and
managed-image exports accept linked storage keys. Environment synchronization
keeps the library metadata and one copy of each file.
# Oversight notification dispatch
New Data Change and Avatar Oversight review items are routed only to agents who
@@ -321,11 +447,11 @@ scheduler instance:
```
The dispatcher groups due rows by reviewer, queue, cadence, and delivery
window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in
`America/New_York`, including daylight-saving transitions. Successfully
dispatched rows retain `delivered_at` evidence and are never selected again.
The existing ISAC notification delivery preferences still determine optional
browser and email fan-out after the consolidated inbox message is created.
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 are excluded from later selection. Existing
ISAC notification delivery preferences determine optional browser and email
fan-out after the consolidated inbox message is created.
## Automated ISAC system messages
+360 -1
View File
@@ -24,10 +24,12 @@
"@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"
"vite": "^8.0.16",
"vitest": "^4.1.10"
}
},
"node_modules/@dotenvx/dotenvx": {
@@ -1962,6 +1964,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1969,6 +1982,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2010,6 +2030,129 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -2063,6 +2206,16 @@
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2073,6 +2226,16 @@
"node": ">= 0.4"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2106,6 +2269,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2183,6 +2353,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -2215,6 +2392,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2692,6 +2879,16 @@
"node": ">=12.20.0"
}
},
"node_modules/opentype.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
"integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
"dev": true,
"license": "MIT",
"bin": {
"ot": "bin/ot"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -2708,6 +2905,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2998,6 +3202,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -3023,6 +3234,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3089,6 +3314,23 @@
"typescript": ">=5.0.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3106,6 +3348,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3259,6 +3511,96 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3274,6 +3616,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-spinner": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz",
+10 -1
View File
@@ -8,6 +8,7 @@
"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",
@@ -22,7 +23,12 @@
"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",
@@ -45,6 +51,7 @@
"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": {
@@ -54,10 +61,12 @@
"@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"
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"dependencies": {
"@fontsource/barlow": "^5.3.0",
+40
View File
@@ -0,0 +1,40 @@
[
{"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"}
]
+481 -10
View File
@@ -4,6 +4,80 @@ import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value, keys) =>
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const isRoll = (value) =>
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (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) =>
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) =>
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) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'];
const isCombatProfile = (value) =>
hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key]));
const isEffectStates = (value) =>
isRecord(value) && Object.values(value).every((entry) => {
if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
combatProfileKeys.includes(key) && isFiniteNumber(override)
)
);
});
const isCurrentLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
&& value.version === 2
&& typeof value.buildName === 'string'
&& hasExactKeys(value.gear, gearSlots)
&& gearSlots.every((slot) => isGearSelection(value.gear[slot]))
&& Array.isArray(value.weapons)
&& value.weapons.length === 2
&& value.weapons.every(isWeaponSelection)
&& Array.isArray(value.chips)
&& value.chips.length === 9
&& value.chips.every(isChipSelection)
&& isProtocolSelection(value.protocol)
&& Array.isArray(value.activeTalents)
&& value.activeTalents.every((talent) => typeof talent === 'string')
&& hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
&& typeof value.specialization.focusId === 'string'
&& isRecord(value.specialization.nodeRanks)
&& Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
&& isEffectStates(value.specialization.effectStates)
&& isCombatProfile(value.specialization.combatProfile);
};
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true;
@@ -37,6 +111,9 @@ const statements = schema
.filter(Boolean);
for (const statement of statements) {
// This index is deferred until the additive legacy-column migration below.
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue;
await database.exec(statement);
}
@@ -103,15 +180,7 @@ const legacyProtocolEffects = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
)).get();
if (legacyProtocolEffects) {
const legacyProtocolEffectCount = await (await database.prepare(
'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects'
)).get();
if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) {
throw new Error(
'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.'
);
}
console.log('Removing the empty legacy OS Protocol attribute-effects table.');
console.log('Removing the retired OS Protocol attribute-effects table.');
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
@@ -183,6 +252,74 @@ if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDe
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const specializationProposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
if (typeof specializationProposalDefinition?.sql === 'string' && !specializationProposalDefinition.sql.includes("'specializations'")) {
console.log('Adding Specializations to moderated data modules.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`CREATE TABLE data_change_proposals_specializations (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
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),
reviewed_by TEXT REFERENCES users(id),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_specializations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec('ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals');
await database.exec('PRAGMA foreign_keys=ON');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const calculationProposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
if (typeof calculationProposalDefinition?.sql === 'string' && !calculationProposalDefinition.sql.includes("'calculations'")) {
console.log('Adding Combat Calculations to moderated data modules.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`CREATE TABLE data_change_proposals_calculations (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
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),
reviewed_by TEXT REFERENCES users(id),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_calculations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec('ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals');
await database.exec('PRAGMA foreign_keys=ON');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
}
const iconDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
)).get();
@@ -201,6 +338,187 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk
await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons');
}
const specializationIconDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
)).get();
if (typeof specializationIconDefinition?.sql === 'string' && !specializationIconDefinition.sql.includes("'specialization_node'")) {
console.log('Adding Specialization icon storage support.');
await database.exec(`CREATE TABLE game_data_icons_specializations (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
)`);
await database.exec(`INSERT INTO game_data_icons_specializations
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
await database.exec('DROP TABLE game_data_icons');
await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons');
}
const managedImageColumns = await (await database.prepare("PRAGMA table_info('managed_images')")).all();
for (const [name, definition] of [
['source_entity_type', 'TEXT'],
['source_entity_id', 'TEXT']
]) {
if (!managedImageColumns.some((column) => column.name === name)) {
console.log(`Linking Image Library assets to ISAC entities: ${name}.`);
await database.exec(`ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`);
}
}
await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx
ON managed_images(source_entity_type,source_entity_id)
WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL`);
const iconLibraryCreator = await (await database.prepare(`SELECT users.id
FROM users
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_roles ON user_roles.user_id=users.id
LEFT JOIN roles ON roles.id=user_roles.role_id
LEFT JOIN role_permissions ON role_permissions.role_id=roles.id
LEFT JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE deleted_accounts.user_id IS NULL
GROUP BY users.id,users.created_at
ORDER BY MAX(CASE WHEN roles.name='Admin' THEN 1
WHEN permissions.name='*' THEN 1 ELSE 0 END) DESC,
users.created_at ASC LIMIT 1`)).get();
const iconLibraryCategory = {
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations'
};
const iconLibraryLabel = {
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node'
};
const normalizeLibraryTags = (values) => [...new Set(values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean))].slice(0, 20);
const linkedIconMetadata = async (entityType, entityId) => {
switch (entityType) {
case 'talent': {
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
WHERE talent.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
}
case 'brand_set': {
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
}
case 'skill_chip': {
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
FROM skill_chip_sets chip
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE chip.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
}
case 'specialization': {
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
FROM specialization_abilities ability
JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE ability.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
specialization.name AS specialization_name
FROM specialization_tree_nodes node
JOIN specialization_focuses focus ON focus.id=node.focus_id
JOIN specializations specialization ON specialization.id=focus.specialization_id
WHERE node.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
}
default:
return { name: null, tags: [] };
}
};
const uniqueLibraryName = async (preferred, entityType, entityId) => {
const candidates = [
preferred,
`${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`,
`${preferred} · ${entityId.slice(0, 8)}`
];
for (const candidate of candidates) {
const normalized = candidate.slice(0, 80);
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
};
if (iconLibraryCreator?.id) {
const unregisteredIcons = await (await database.prepare(`SELECT icon.*
FROM game_data_icons icon
LEFT JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
WHERE image.id IS NULL
ORDER BY icon.entity_type,icon.entity_id`)).all();
if (unregisteredIcons.length) console.log(`Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`);
for (const icon of unregisteredIcons) {
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset';
const preferredName = String(metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`);
const name = await uniqueLibraryName(preferredName, String(icon.entity_type), String(icon.entity_id));
const tags = normalizeLibraryTags([
'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
]);
const now = String(icon.updated_at ?? new Date().toISOString());
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(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)
||'-'||substr('89ab',abs(random())%4+1,1)||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(6))),
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
name, iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), 1, 0,
icon.storage_key, icon.storage_key, icon.byte_size, 256, 256,
icon.entity_type, icon.entity_id, iconLibraryCreator.id, now, now
);
}
const linkedIcons = await (await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json
FROM game_data_icons icon
JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
ORDER BY icon.entity_type,icon.entity_id`)).all();
for (const icon of linkedIcons) {
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
const preferredName = String(metadata.name ?? `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`);
let existingTags = [];
try { existingTags = JSON.parse(String(icon.tags_json)); } catch { /* replace malformed legacy tags below */ }
existingTags = existingTags.filter((tag) => String(tag).toLocaleLowerCase() !== 'vest');
const tags = normalizeLibraryTags([
...existingTags, 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
]);
await (await database.prepare(`UPDATE managed_images SET
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256
WHERE id=?`)).run(
iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags),
icon.storage_key, icon.storage_key, icon.byte_size, icon.library_image_id
);
}
}
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
@@ -290,14 +608,42 @@ if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) {
const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all();
for (const [name, definition] of [
['description', 'TEXT'],
['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'],
['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)']
]) {
if (!attributeColumns.some((column) => column.name === name)) {
console.log(`Recording the reference level for Attribute source ranges: ${name}.`);
console.log(name === 'description'
? 'Adding optional Attribute descriptions.'
: `Recording the reference level for Attribute source ranges: ${name}.`);
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
}
}
const nameOnlyAttributeIndex = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='index' AND name='stat_attributes_name_nocase_idx'"
)).get();
if (nameOnlyAttributeIndex) {
console.log('Allowing Attribute names to repeat when their units differ.');
await database.exec('DROP INDEX stat_attributes_name_nocase_idx');
}
for (const [previousName, currentName] of [
['Engineering %', 'Engineering'],
['Firepower %', 'Firepower'],
['Toughness %', 'Toughness'],
['Weapon Damage %', 'Weapon Damage']
]) {
const previous = await (await database.prepare(
'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?'
)).get(previousName, '%');
const current = await (await database.prepare(
'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?'
)).get(currentName, '%');
if (previous && !current) {
await (await database.prepare('UPDATE stat_attributes SET name=?,updated_at=? WHERE id=?'))
.run(currentName, new Date().toISOString(), previous.id);
}
}
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)');
const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all();
for (const [name, definition] of [
@@ -366,9 +712,134 @@ if (buildColumns.length && !buildColumns.some((column) => column.name === 'loado
}
if (buildColumns.length) {
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'");
const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all();
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
if (obsoleteBuildIds.length) {
console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`);
await database.exec('BEGIN');
try {
for (const id of obsoleteBuildIds) {
await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id);
await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json');
if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) {
console.log('Requiring every build record to contain a current loadout snapshot.');
await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS builds_current');
await database.exec(`CREATE TABLE builds_current (
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_current(id),
loadout_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at)
SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`);
await database.exec('DROP TABLE builds');
await database.exec('ALTER TABLE builds_current RENAME TO builds');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
}
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
const skillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
if (skillChipColumns.length && !skillChipColumns.some((column) => column.name === 'specialization_ability_id')) {
console.log('Associating Skill Chip definitions with specialization abilities.');
await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)');
}
const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id');
if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) {
const unassignedSkillChips = await (await database.prepare(
'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL'
)).all();
if (unassignedSkillChips.length) {
console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`);
for (const row of unassignedSkillChips) {
await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id);
}
}
console.log('Requiring every Skill Chip definition to reference a specialization ability.');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current');
await database.exec(`CREATE TABLE skill_chip_sets_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at)
SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`);
await database.exec('DROP TABLE skill_chip_sets');
await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)');
await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)');
const calibrationCases = await (await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases'
)).all();
const obsoleteCalibrationIds = calibrationCases
.filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json))
.map((calibrationCase) => String(calibrationCase.id));
if (obsoleteCalibrationIds.length) {
console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`);
for (const id of obsoleteCalibrationIds) {
await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id);
}
}
const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all();
if (foreignKeyFailures.length) {
throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`);
}
await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
WHERE builder_ready=1 AND (
SELECT COUNT(*) FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=specialization_focuses.id AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3
)<>3`);
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') {
+1 -1
View File
@@ -47,7 +47,7 @@ try {
};
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} game-data icons.`);
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 });
+3 -3
View File
@@ -70,7 +70,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -80,7 +80,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
@@ -111,5 +111,5 @@ for (const file of bundle.files.gameIcons) {
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} game-data icons.`);
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}`);
+124
View File
@@ -0,0 +1,124 @@
import { createServer } from 'vite';
import { readFile } from 'node:fs/promises';
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 records = JSON.parse(await readFile(sourcePath, 'utf8'));
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 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
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.');
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(
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}`);
} finally {
await vite.close();
}
+7 -3
View File
@@ -23,11 +23,15 @@ for (const name of ISAC_DATA_TABLES) {
}
const icons = [];
const exportedStorageKeys = new Set();
const iconTable = tables.find((table) => table.name === 'game_data_icons');
for (const row of iconTable?.rows ?? []) {
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 game-data icon storage key "${String(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') });
}
@@ -44,5 +48,5 @@ const snapshot = {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded icons.`);
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`);
console.log(`ISAC snapshot: ${outputPath}`);
+1 -1
View File
@@ -74,6 +74,6 @@ for (const icon of snapshot.icons) {
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
}
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons 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}`);
+21 -1
View File
@@ -48,7 +48,27 @@ export const ISAC_DATA_TABLES = [
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'game_data_icons'
'specialization_progression_config',
'specialization_rank_cost_profiles',
'upgrade_resources',
'specializations',
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'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',
'calculation_calibration_cases',
'game_data_icons',
'managed_images'
];
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
+29
View File
@@ -0,0 +1,29 @@
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 database = await connect(databasePath);
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') });
}
const bundle = {
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' });
console.log(`Exported ${images.length} managed image assets.`);
console.log(`Image bundle: ${outputPath}`);
+113
View File
@@ -0,0 +1,113 @@
import { connect } from '@tursodatabase/database';
import { spawnSync } from 'node:child_process';
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('--'));
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);
}
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.');
}
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.');
}
if (image.row.source_entity_type && (
typeof image.row.source_entity_id !== 'string'
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|| !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
});
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 (assetBackup.error) throw assetBackup.error;
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 database = await connect(databasePath);
await mkdir(storageDirectory, { recursive: true });
let inserted = 0;
let updated = 0;
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 });
}
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
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
(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');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
console.log(`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`);
+163
View File
@@ -0,0 +1,163 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import opentype from 'opentype.js';
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);
fs.mkdirSync(outputDirectory, { recursive: true });
const colors = {
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 };
}
function emblem(x, y, size) {
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"/>
<path d="M85 37A38 38 0 0 1 55 88"/>
<path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="10" height="10" fill="${colors.orange}"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="${colors.white}" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
</g>`;
}
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));
}
const transparentLogo = `
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
${emblem(72, 72, 880)}
</svg>`;
const serverIcon = `
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="${colors.background}"/>
<path d="M0 192H1024M0 512H1024M0 832H1024M192 0V1024M512 0V1024M832 0V1024" stroke="${colors.grid}" stroke-width="2" opacity=".32"/>
<rect x="96" y="96" width="832" height="832" fill="none" stroke="${colors.border}" stroke-width="3"/>
${emblem(132, 132, 760)}
</svg>`;
const shd = trackedPath(heavy, 'SHD', 0, 0, 190, -3);
const build = trackedPath(heavy, '.build', 0, 0, 190, -2);
const descriptor = trackedPath(medium, 'RESURGENCE DATA & TOOLS', 0, 0, 43, 7);
const wordmarkWidth = shd.width + build.width;
const lockupX = 600;
const wordmarkBaseline = 590;
const banner = `
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
<defs>
<radialGradient id="signal" cx="34%" cy="58%" r="54%">
<stop offset="0" stop-color="#2b2416"/>
<stop offset=".44" stop-color="#141617"/>
<stop offset="1" stop-color="${colors.background}"/>
</radialGradient>
<linearGradient id="fade" x1="0" x2="1">
<stop stop-color="${colors.orange}" stop-opacity=".22"/>
<stop offset=".7" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="${colors.background}"/>
<rect y="96" width="1920" height="984" fill="url(#signal)"/>
<path d="M0 96H1920M0 864H1920M192 96V1080M1728 96V1080" stroke="${colors.border}" stroke-width="2"/>
<path d="M0 224H1920M0 352H1920M0 480H1920M0 608H1920M0 736H1920M320 96V1080M448 96V1080M576 96V1080M704 96V1080M832 96V1080M960 96V1080M1088 96V1080M1216 96V1080M1344 96V1080M1472 96V1080M1600 96V1080" stroke="${colors.grid}" stroke-width="1" opacity=".22"/>
<path d="M192 864H1260" stroke="url(#fade)" stroke-width="3"/>
<path d="M1534 210h194v194" fill="none" stroke="${colors.orange}" stroke-width="4" opacity=".42"/>
<path d="M386 756A272 272 0 0 1 386 212" fill="none" stroke="${colors.border}" stroke-width="2"/>
${emblem(188, 314, 330)}
<g transform="translate(${lockupX} ${wordmarkBaseline})">
<path d="${shd.data}" fill="${colors.white}"/>
<path d="${build.data}" fill="${colors.orange}" transform="translate(${shd.width} 0)"/>
<rect y="34" width="${Math.max(wordmarkWidth, descriptor.width)}" height="2" fill="${colors.border}"/>
<path d="${descriptor.data}" fill="${colors.white}" transform="translate(0 98)"/>
</g>
<g transform="translate(1540 820)">
<rect width="188" height="56" fill="${colors.surface}" stroke="${colors.border}" stroke-width="2"/>
<rect x="16" y="23" width="10" height="10" fill="${colors.orange}"/>
<path d="M42 28H158" stroke="${colors.orange}" stroke-width="2"/>
<path d="M166 22l8 6-8 6" fill="none" stroke="${colors.orange}" stroke-width="2"/>
</g>
</svg>`;
const inviteSplash = `
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
<defs>
<radialGradient id="left-signal" cx="8%" cy="52%" r="58%">
<stop offset="0" stop-color="#3b2d15"/>
<stop offset=".38" stop-color="#171819"/>
<stop offset="1" stop-color="${colors.background}"/>
</radialGradient>
<radialGradient id="right-signal" cx="94%" cy="45%" r="50%">
<stop offset="0" stop-color="#241e13"/>
<stop offset=".48" stop-color="#111415"/>
<stop offset="1" stop-color="${colors.background}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="relay-left" x1="0" x2="1">
<stop stop-color="${colors.orange}" stop-opacity=".8"/>
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
<linearGradient id="relay-right" x1="1" x2="0">
<stop stop-color="${colors.orange}" stop-opacity=".55"/>
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="${colors.background}"/>
<rect width="1920" height="1080" fill="url(#left-signal)"/>
<rect width="1920" height="1080" fill="url(#right-signal)"/>
<path d="M0 120H1920M0 960H1920M160 0V1080M1760 0V1080" stroke="${colors.border}" stroke-width="2"/>
<path d="M0 288H1920M0 456H1920M0 624H1920M0 792H1920M328 0V1080M496 0V1080M664 0V1080M832 0V1080M1000 0V1080M1168 0V1080M1336 0V1080M1504 0V1080" stroke="${colors.grid}" stroke-width="1" opacity=".16"/>
<g opacity=".92">${emblem(-92, 224, 620)}</g>
<g opacity=".66">${emblem(1465, 326, 410)}</g>
<path d="M0 170H605" stroke="url(#relay-left)" stroke-width="4"/>
<path d="M0 910H500" stroke="url(#relay-left)" stroke-width="3"/>
<path d="M1335 188H1920" stroke="url(#relay-right)" stroke-width="3"/>
<path d="M1450 892H1920" stroke="url(#relay-right)" stroke-width="4"/>
<g fill="${colors.orange}">
<rect x="560" y="164" width="12" height="12"/>
<rect x="486" y="904" width="10" height="10"/>
<rect x="1348" y="182" width="12" height="12"/>
<rect x="1436" y="886" width="12" height="12"/>
</g>
<path d="M610 170h90M1310 188h-92M540 910h118M1400 892h-112" stroke="${colors.white}" stroke-width="2" opacity=".22"/>
<rect x="650" y="120" width="620" height="840" fill="${colors.background}" opacity=".42"/>
</svg>`;
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'));
console.log(`Rendered brand assets to ${outputDirectory}`);
+2 -2
View File
@@ -64,7 +64,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -74,7 +74,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
+1945 -33
View File
File diff suppressed because one or more lines are too long
+23 -1
View File
@@ -2,6 +2,7 @@ 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';
export const handle: Handle = async ({ event, resolve }) => {
@@ -9,12 +10,33 @@ export const handle: Handle = async ({ event, resolve }) => {
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) {
const user = await getSessionUser(event.cookies);
// 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) {
+424
View File
@@ -0,0 +1,424 @@
import {
calculateCombat,
type AttributeBinding,
type CalculationStage
} from '$lib/calculations/combat';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
type Catalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
calculation: { revision: any | null; stages: any[]; profiles: any[] };
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
activeBonuses: any[];
equippedTalents: any[];
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
return catalog.specializations
.flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization })))
.find((focus: any) => focus.id === draft.specialization.focusId);
}
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
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) }]
: [];
}) ?? [];
}
function attribute(catalog: Catalog, id: string) {
return catalog.attributes.find((item) => item.id === id);
}
function unitValue(catalog: Catalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: Catalog, 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: Catalog, 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 || 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: Catalog): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(catalog, statId);
const current = values.get(statId) ?? 0;
const incoming = Number(value);
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(
statId,
stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_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,
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,
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,
tier: group.highEnd,
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,
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;
})
});
}
}
for (const equipped of equippedTalents) {
if (!draft.activeTalents.includes(equipped.key)) continue;
const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? [];
for (const effect of effects) {
const value = equipped.key.startsWith('chips:')
? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
: equipped.key.startsWith('protocol:')
? Number(effect.base_value) * (1 + equipped.tier / 5)
: effect.value;
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
}
}
return {
stats: [...values]
.map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 }))
.sort((left, right) =>
(attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '')
|| (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '')
),
activeBonuses,
equippedTalents
};
}
function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null {
if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) {
return stageKey as CalculationStage;
}
const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey);
if (!stage) return null;
if (stage.operator === 'flat_add') return 'flat_damage';
if (stage.operator === 'additive_percent') return 'weapon_damage';
if (stage.operator === 'multiplicative_percent') return 'final_damage';
if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage';
if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage';
return null;
}
function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] {
return catalog.calculation.profiles
.filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category)
.flatMap((profile) => {
const stage = calculationStage(catalog, String(profile.stage_key));
return stage
? [{
attributeId: String(profile.stat_attribute_id),
stage,
operator: 'add' as const,
weight: Number(profile.weight),
softCap: profile.soft_cap,
hardCap: profile.hard_cap,
curveType: profile.curve_type,
curveParameter: profile.curve_parameter
}]
: [];
});
}
export function evaluateLoadoutCombat(
draft: LoadoutDraft,
catalog: Catalog,
summary = summarizeLoadout(draft, catalog)
): WeaponCalculationResult[] {
const focus = selectedFocus(draft, catalog);
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
);
const required = (weaponIndex: number) => {
const missing: string[] = [];
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles');
if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`);
if (!draft.protocol.itemId) missing.push('OS Protocol');
if (!draft.specialization.focusId) missing.push('Specialization focus');
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
const missingChips = draft.chips.filter((chip) => !chip.setId).length;
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`);
return missing;
};
return [0, 1].map((weaponIndex) => {
const selection = draft.weapons[weaponIndex];
const weapon = catalog.weapons.find((item) => item.id === selection.itemId);
const missing = required(weaponIndex);
if (missing.length) {
return {
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing,
revisionLabel: catalog.calculation.revision
? `RULES R${catalog.calculation.revision.version}`
: undefined
};
}
const bindings = calculationBindings(catalog, weapon);
const effects = combatEffects.map((effect: any) => ({
id: String(effect.id),
trigger: {
hitsRequired: Number(effect.trigger_count),
windowSeconds: Number(effect.trigger_window_seconds)
},
lifecycle: {
maxStacks: Number(effect.max_stacks),
durationSeconds: Number(effect.duration_seconds),
stacksPerTrigger: Number(effect.stacks_per_trigger)
},
attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [
String(modifier.stat_attribute_id),
Number(modifier.value_per_stack)
]))
}));
const perEffect = Object.fromEntries(combatEffects.map((effect: any) => {
const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' };
return [effect.id, {
mode: state.mode,
...(state.overrides ?? {})
}];
}));
const result = calculateCombat({
weapon: {
baseDamage: selection.damage,
rpm: Number(weapon?.rpm),
magazine: Number(weapon?.magazine),
reloadSeconds: Number(weapon?.reload_speed),
headshotMultiplier: 2
},
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
bindings,
stageOrder: catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null),
stackingEffects: effects,
assumptions: {
mode: 'off',
...draft.specialization.combatProfile,
perEffect
}
});
if (result.status === 'INSUFFICIENT_DATA') {
return {
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing: result.missing.map((item) =>
item.startsWith('binding:')
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
: item.replaceAll('.', ' ')
),
revisionLabel: `RULES R${catalog.calculation.revision.version}`
};
}
return {
weaponName: weapon.name,
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'complete',
bodyHit: result.hitValues?.normal,
criticalBodyHit: result.hitValues?.critical,
headshot: result.hitValues?.headshot,
criticalHeadshot: result.hitValues?.criticalHeadshot,
burstDps: result.burstDps ?? undefined,
sustainedDps: result.sustainedDps ?? undefined,
encounterDps: result.encounterDps ?? undefined,
effectSummaries: result.stackSummaries.map((stack) => {
const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId);
return {
id: stack.effectId,
name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'),
mode: stack.mode,
averageStacks: stack.averageStacks,
maxStacksReached: stack.maxStacksReached,
maxStacks: Number(effect?.max_stacks ?? 0),
stacksAtEnd: stack.stacksAtEnd,
buildUpSeconds: stack.buildUpSeconds,
maintained: stack.maintained
};
}),
revisionLabel: `RULES R${catalog.calculation.revision.version}`
};
});
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
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);
expect(parsed).toEqual(draft);
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);
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 2 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects a specialization missing a required current field', () => {
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
delete draft.specialization.combatProfile;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects a specialization that carries retired unassigned chips', () => {
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
draft.specialization.unassignedChips = [];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects malformed nested equipment selections', () => {
const draft = copyDraft();
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
expect(parseLoadoutDraft(draft)).toBeNull();
});
});
+101 -5
View File
@@ -3,14 +3,24 @@ export type GearSelection = { itemId: string; level: number; brandId: string; ta
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; firingActivity: number };
export type EffectMode = 'off' | 'peak' | 'encounter';
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
export type SpecializationSelection = {
focusId: string;
nodeRanks: Record<string, number>;
effectStates: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile;
};
export type LoadoutDraft = {
version: 1;
version: 2;
buildName: string;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [ChipSelection, ChipSelection, ChipSelection];
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
protocol: ProtocolSelection;
activeTalents: string[];
specialization: SpecializationSelection;
};
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
@@ -18,16 +28,102 @@ 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 defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, firingActivity: 80 });
export const createDraft = (): LoadoutDraft => ({
version: 1,
version: 2,
buildName: 'Untitled loadout',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [blankChip(), blankChip(), blankChip()],
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
activeTalents: []
activeTalents: [],
specialization: {
focusId: '',
nodeRanks: {},
effectStates: {},
combatProfile: defaultCombatProfile()
}
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
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);
const isNumberRecord = (value: unknown) =>
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);
const isRollPair = (value: unknown): value is [Roll, Roll] =>
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);
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);
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);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
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', 'firingActivity'])
&& ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) && Object.values(value).every((entry) => {
if (!isRecord(entry)
|| !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
)
);
});
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
if (!isRecord(value)
|| !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
|| value.version !== 2
|| typeof value.buildName !== 'string') 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) || !Array.isArray(value.activeTalents)
|| !value.activeTalents.every((talent) => typeof talent === 'string')) return null;
if (!isRecord(value.specialization)
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|| typeof value.specialization.focusId !== 'string'
|| !isNumberRecord(value.specialization.nodeRanks)
|| !isEffectStates(value.specialization.effectStates)
|| !isCombatProfile(value.specialization.combatProfile)) 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;
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { calculateCombat, 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
});
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('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('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('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'
]));
});
});
+405
View File
@@ -0,0 +1,405 @@
export type CalculationStage =
| 'flat_damage'
| 'weapon_damage'
| 'critical_chance'
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| '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;
};
export type StatValues = Record<string, number>;
export type WeaponCombatInput = {
baseDamage?: number;
rpm?: number;
magazine?: number;
reloadSeconds?: number;
headshotMultiplier?: 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 StackingEffect = {
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'>>>;
};
export type MissingRequirement =
| '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;
};
export type StackSummary = {
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[];
};
export type CombatCalculationInput = {
weapon: WeaponCombatInput;
attributes: StatValues;
bindings: AttributeBinding[];
stageOrder?: CalculationStage[];
stackingEffects?: StackingEffect[];
assumptions: CombatAssumptions;
};
type ResolvedAssumptions = {
mode: StackingMode;
hitRate: number;
headshotRate: number;
firingActivity: number;
encounterSeconds: number;
};
type StackState = {
stacks: number;
expiresAt: number;
maxStacksReached: number;
buildUpSeconds: number | null;
};
type TimedStackEffect = {
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;
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;
}
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)];
}
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[]
) {
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[]
): 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
};
}
function dpsFor(
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 shotsPerSecond = weapon.rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds)
};
}
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;
}
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)
};
})
};
}
+22 -14
View File
@@ -1,23 +1,32 @@
<script lang="ts">
import { page } from '$app/state';
import BrandLockup from '$lib/components/BrandLockup.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { onMount, tick } from 'svelte';
import type { Snippet } from 'svelte';
let { user, isacAccess, notificationSummary, children }: {
let { user, isacAccess, notificationSummary, siteAccess, children }: {
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
isacAccess: boolean;
notificationSummary: { unread: number };
siteAccess: { comingSoonEnabled: boolean; sections: Record<string, boolean> };
children: Snippet;
} = $props();
const links = [
{ href: '/news', label: 'News' },
{ href: '/database', label: 'Database' },
{ href: '/builds', label: 'Builds' },
{ href: '/guides', label: 'Guides' },
{ href: '/builder', label: 'Builder' }
const allLinks = [
{ href: '/news', label: 'News', key: 'news' },
{ href: '/database', label: 'Database', key: 'database' },
{ href: '/builds', label: 'Builds', key: 'builds' },
{ href: '/guides', label: 'Guides', key: 'guides' },
{ href: '/builder', label: 'Builder', key: 'builder' }
];
const links = $derived(allLinks.filter((link) => siteAccess.sections[link.key]));
const launchSupportPages = ['/contact', '/dmca', '/terms', '/sign-in'];
const homeHref = $derived(
siteAccess.comingSoonEnabled && launchSupportPages.some((path) => page.url.pathname === path || page.url.pathname.startsWith(`${path}/`))
? '/coming-soon'
: '/'
);
let showCookieNotice = $state(false);
let mobileOpen = $state(false);
@@ -102,8 +111,8 @@
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
<header class="site-header">
<a class="brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
<a class="brand" href={homeHref} aria-label="SHD.build home">
<BrandLockup />
</a>
<nav aria-label="Main navigation">
{#each links as link}
@@ -162,8 +171,8 @@
<div class="mobile-nav-layer">
<button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button>
<nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}>
<div class="mobile-nav-heading"><div><span>SHD COMMUNITY</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
<a class:active={page.url.pathname === '/'} href="/"><small>00</small><span>Home</span><b>›</b></a>
<div class="mobile-nav-heading"><div><span>SHD NETWORK</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
<a class:active={page.url.pathname === '/' || page.url.pathname === '/coming-soon'} href={homeHref}><small>00</small><span>Home</span><b>›</b></a>
{#each links as link, index}
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each}
@@ -186,9 +195,8 @@
<footer class="site-footer">
<div>
<a class="brand footer-brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span>
<span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
<a class="brand footer-brand" href={homeHref} aria-label="SHD.build home">
<BrandLockup size="footer" />
</a>
<p>Independent builds, guides, game data, news, and community intelligence for The Division Resurgence.</p>
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
let { size = '100%' }: { size?: string } = $props();
</script>
<svg class="brand-emblem" viewBox="0 0 100 100" aria-hidden="true" style:width={size} style:height={size}>
<g fill="none" stroke="#f6a821" stroke-width="12" stroke-linecap="butt">
<path d="M73 20A38 38 0 0 0 15 63"/>
<path d="M85 37A38 38 0 0 1 55 88"/>
<path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="10" height="10" fill="#f6a821"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="#ecebe5" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
</svg>
<style>
.brand-emblem {
display: block;
flex: 0 0 auto;
overflow: visible;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import BrandEmblem from '$lib/components/BrandEmblem.svelte';
let {
size = 'header',
descriptor = 'RESURGENCE DATA & TOOLS'
}: {
size?: 'header' | 'footer' | 'launch';
descriptor?: string;
} = $props();
</script>
<span class="brand-lockup brand-lockup--{size}">
<span class="brand-lockup__emblem"><BrandEmblem /></span>
<span class="brand-lockup__copy">
<span class="brand-lockup__name"><span>SHD</span><b>.build</b></span>
<small>{descriptor}</small>
</span>
</span>
<style>
.brand-lockup {
--brand-emblem-size: 38px;
--brand-name-size: 20px;
display: inline-flex;
align-items: center;
gap: 11px;
min-width: max-content;
}
.brand-lockup__emblem {
display: block;
width: var(--brand-emblem-size);
height: var(--brand-emblem-size);
flex: 0 0 auto;
}
.brand-lockup__copy {
display: grid;
gap: 3px;
}
.brand-lockup__name {
display: flex;
align-items: baseline;
color: var(--shd-text);
font-size: var(--brand-name-size);
font-weight: 900;
letter-spacing: -.025em;
line-height: .82;
}
.brand-lockup__name b {
color: var(--shd-orange);
font-weight: inherit;
}
.brand-lockup small {
color: var(--shd-text-muted);
font-size: 9px;
font-weight: 800;
letter-spacing: .13em;
line-height: 1;
white-space: nowrap;
}
.brand-lockup--footer {
--brand-emblem-size: 36px;
--brand-name-size: 18px;
}
.brand-lockup--launch {
--brand-emblem-size: 42px;
--brand-name-size: 22px;
}
@media (max-width: 430px) {
.brand-lockup--header,
.brand-lockup--launch {
--brand-emblem-size: 36px;
}
.brand-lockup--header .brand-lockup__copy,
.brand-lockup--launch .brand-lockup__copy {
display: none;
}
}
</style>
@@ -3,10 +3,10 @@
let { active = '' }: { active?: string } = $props();
</script>
<nav class="database-category-nav" aria-label="Game database categories">
<nav class="database-category-nav" aria-label="The Division Resurgence database categories">
{#each databaseCategories as category}
<a class:active={active === category.slug} aria-current={active === category.slug ? 'page' : undefined} href={`/database/${category.slug}`}>
<small>{category.code}</small><span>{category.label}</span><b>›</b>
<span>{category.label}</span><b>›</b>
</a>
{/each}
</nav>
@@ -0,0 +1,21 @@
<script lang="ts">
import DatabaseFacetFilters from './DatabaseFacetFilters.svelte';
import RegistryPagination from './RegistryPagination.svelte';
let {
filters = [],
pagination,
search,
searchPlaceholder = 'SEARCH BY NAME'
}: {
filters?: any[];
pagination: { page: number; perPage: number; total: number; pageCount: number };
search: string;
searchPlaceholder?: string;
} = $props();
</script>
<section class="database-discovery-console" aria-label="Database search, filters, and pagination">
{#if filters.length}<DatabaseFacetFilters groups={filters}/>{/if}
<RegistryPagination {pagination} {search} {searchPlaceholder}/>
</section>
+29 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import GameIcon from './GameIcon.svelte';
import ItemIcon from './ItemIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
@@ -9,6 +10,9 @@
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
let { groups }: { groups: Group[] } = $props();
let queries = $state<Record<string, string>>({});
let expanded = $state(true);
let observedSelectedCount = 0;
const disclosureKey = 'shd.database.filters.expanded';
const change = (key: string, value: string) => {
const params = new URLSearchParams(page.url.searchParams);
@@ -32,11 +36,33 @@
|| option.searchText?.toLocaleLowerCase().includes(query));
};
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
$effect(() => {
if (selectedCount > observedSelectedCount) expanded = true;
observedSelectedCount = selectedCount;
});
const toggleExpanded = () => {
expanded = !expanded;
sessionStorage.setItem(disclosureKey, expanded ? 'true' : 'false');
};
onMount(() => {
if (selectedCount) {
expanded = true;
return;
}
const stored = sessionStorage.getItem(disclosureKey);
expanded = stored === null ? !window.matchMedia('(max-width: 700px)').matches : stored === 'true';
});
</script>
<section class="database-facet-panel" aria-label="Database filters">
<header><div><small>TACTICAL FILTER MATRIX</small><strong>MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP</strong></div>{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount} FILTER{selectedCount === 1 ? '' : 'S'}</button>{/if}</header>
<div class:compact={groups.length === 1} class="database-facet-groups">
<section class:collapsed={!expanded} class="database-facet-panel" aria-label="Database filters">
<header>
<div><small>TACTICAL FILTER MATRIX</small><strong>{selectedCount ? `${selectedCount} ACTIVE · MATCH EVERY ACTIVE GROUP` : 'MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP'}</strong></div>
<div class="database-facet-actions">
{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount}</button>{/if}
<button type="button" class="database-facet-toggle" aria-expanded={expanded} aria-controls="database-facet-groups" onclick={toggleExpanded}>{expanded ? 'HIDE FILTERS' : `SHOW FILTERS${selectedCount ? ` · ${selectedCount} ACTIVE` : ''}`} <b aria-hidden="true">{expanded ? '−' : '+'}</b></button>
</div>
</header>
<div id="database-facet-groups" hidden={!expanded} class:compact={groups.length === 1} class="database-facet-groups">
{#each groups as group}
<fieldset class:searchable={group.kind === 'searchable'} class="database-facet-group">
<legend>{group.label}<span>{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'}</span></legend>
+4 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
currentUrl = '',
label = 'ICON',
@@ -9,7 +11,7 @@
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl={MISSING_ITEM_ICON_URL} {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
<small>OPTIONAL · SAVE NOW, ADD AN ICON LATER · SVG, PNG, WEBP, AVIF, OR JPEG · BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
</fieldset>
+5 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
src = '',
alt = '',
@@ -8,6 +10,8 @@
alt?: string;
className?: string;
} = $props();
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || '/missing-item-icon.svg'} {alt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
+3 -2
View File
@@ -16,8 +16,9 @@
<nav class={`module-kpis kpi-filters ${className}`} aria-label="Registry filters">
{#each items as item}
<a href={href(item.value)} class:active={active === item.value} aria-current={active === item.value ? 'true' : undefined}>
<span>{item.label}</span><strong>{item.count}</strong><small>{item.description}</small>
{@const selected = active === item.value}
<a href={href(item.value)} class:active={selected} aria-current={selected ? 'page' : undefined} aria-label={`${item.label}: ${item.count}. ${item.description}. ${selected ? 'Current filter' : 'Apply filter'}`}>
<span>{item.label}</span><i aria-hidden="true">{selected ? 'SELECTED' : 'FILTER →'}</i><strong>{item.count}</strong><small>{item.description}</small>
</a>
{/each}
</nav>
@@ -35,6 +35,88 @@
};
return proposed[names[key] ?? key];
};
const specializationKind = $derived(
action === 'saveProgression' ? 'PROGRESSION PROFILE'
: action === 'saveSpecialization' ? 'SPECIALIZATION'
: action === 'saveFocus' ? 'FOCUS'
: action === 'saveAbility' ? 'CLASS ABILITY'
: action === 'saveCombatEffect' ? 'SHARED COMBAT EFFECT'
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived(
action === 'saveProgression'
? [
{ label: 'XP POINTS', current: specializationRecord?.xp_points, proposed: proposed.xpPoints },
{ label: 'SHD TECH CACHE POINTS', current: specializationRecord?.shd_cache_points, proposed: proposed.cachePoints }
]
: action === 'saveSpecialization'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveFocus'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'PRIMARY ATTRIBUTE', current: specializationRecord?.primary_attribute, proposed: proposed.primaryAttribute },
{ label: 'DISPLAY ORDER', current: specializationRecord?.display_order, proposed: proposed.displayOrder },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveAbility'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: action === 'saveNode'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'FOCUS', current: specializationRecord?.focus_id, proposed: proposed.focusId },
{ label: 'ROW', current: specializationRecord?.row_number, proposed: proposed.rowNumber },
{ label: 'COLUMN', current: specializationRecord?.column_number, proposed: proposed.columnNumber },
{ label: 'MAXIMUM RANK', current: specializationRecord?.max_rank, proposed: proposed.maxRank },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: [
{ label: 'FOCUS', current: specializationRecord?.name ?? specializationRecord?.id, proposed: proposed.id },
{ label: 'ABILITY SLOT 1 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 1)?.ability_id, proposed: proposed.abilityId1 },
{ label: 'ABILITY SLOT 2 · COMBAT SKILLS', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 2)?.ability_id, proposed: proposed.abilityId2 },
{ label: 'ABILITY SLOT 3 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 3)?.ability_id, proposed: proposed.abilityId3 }
]
);
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
</script>
<section class="proposal-change-preview" aria-label="Proposed data comparison">
@@ -50,6 +132,7 @@
{#if currentSnapshot}
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('current', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
@@ -66,6 +149,7 @@
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('proposed', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
@@ -76,6 +160,106 @@
</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
</div>
</article>
</div>
{:else if module === 'calculations'}
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if specializationRecord}
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
<section><header><span>RANK {profile.rank}</span><b>{profile.point_cost} POINTS</b></header><p>{Number(profile.credit_cost).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
{:else}
<div class="proposal-empty-state"><b>NEW {specializationKind}</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {specializationKind}</span><b>AFTER</b></header>
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
<section><header><span>RANK {rank}</span><b>{shown(proposed[`rank${rank}PointCost`])} POINTS</b></header><p>{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
</article>
</div>
{:else}
<div class="proposal-editor-preview generic after">
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
label,
children
}: {
id: string;
label: string;
children: Snippet;
} = $props();
</script>
<section class="registry-inline-editor" {id} aria-label={label}>
{@render children()}
</section>
+2 -2
View File
@@ -73,7 +73,7 @@
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
<button class="secondary" aria-busy={searchPending} disabled={searchPending}>{searchPending ? 'SEARCHING…' : 'SEARCH'}</button>
{#if searchPending}<span class="pagination-search-state pending" aria-live="polite">SEARCHING…</span>{/if}
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
</form>
{/if}
@@ -87,6 +87,6 @@
{/each}
<a class:disabled={pagination.page === pagination.pageCount} aria-disabled={pagination.page === pagination.pageCount} href={href(Math.min(pagination.pageCount, pagination.page + 1))}>NEXT →</a>
</div>
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
{/if}
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
</nav>
@@ -1,5 +1,5 @@
<script lang="ts">
type Item = { id: string; name: string; pending?: boolean; proposerName?: string };
type Item = { id: string; name: string; unit?: string; pending?: boolean; proposerName?: string };
let {
items,
selected = $bindable<string[]>([]),
@@ -33,10 +33,12 @@
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
const available = $derived(items.filter((item) =>
!excludedIds.includes(item.id)
&& (!normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery))
&& (!normalizedQuery || `${item.name} ${item.unit ?? ''}`.toLocaleLowerCase().includes(normalizedQuery))
));
const visible = $derived(available.slice(0, 30));
const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item)));
const unitLabel = (item: Item) => item.unit === undefined ? '' : item.unit || 'FLAT';
const accessibleName = (item: Item) => unitLabel(item) ? `${item.name}, unit ${unitLabel(item)}` : item.name;
const toggle = (id: string) => {
if (mode === 'single') {
value = value === id ? '' : id;
@@ -57,7 +59,7 @@
<div class="assignment-summary"><span>{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}</span><b>{selectedIds.length}</b></div>
<div class="assignment-chips">
{#each selectedItems as item}
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${item.name}`}><span>{item.name}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}><span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
{:else}
<small>{emptySelectionLabel}</small>
{/each}
@@ -68,7 +70,7 @@
<div class="assignment-results" role="list">
{#each visible as item}
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} aria-pressed={selectedIds.includes(item.id)} onclick={() => toggle(item.id)}>
<span>{item.name}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
</button>
{:else}
<p>{emptyLabel}</p>
+3 -3
View File
@@ -8,6 +8,7 @@
className?: string;
icon?: GameIconName;
iconOnly?: boolean;
disabled?: boolean;
};
let {
@@ -19,7 +20,6 @@
selected = $bindable([]),
columns = options.length,
required = false,
showIndicator = true,
onchange
}: {
legend: string;
@@ -30,7 +30,6 @@
selected?: string[];
columns?: number;
required?: boolean;
showIndicator?: boolean;
onchange?: (values: string[]) => void;
} = $props();
@@ -56,10 +55,11 @@
value={option.value}
checked={mode === 'radio' ? value === option.value : selected.includes(option.value)}
{required}
disabled={option.disabled}
onchange={(event) => change(option.value, event.currentTarget.checked)}
/>
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else if showIndicator}<i aria-hidden="true"></i>{/if}
{#if option.icon}<GameIcon name={option.icon} decorative/>{/if}
<b class:sr-only={option.iconOnly}>{option.label}</b>
</span>
</label>
@@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import type { RegistrySort, RegistrySortDirection } from '$lib/server/pagination';
let {
label,
column,
sort,
namespace = '',
defaultDirection = 'asc',
class: className = ''
}: {
label: string;
column: string;
sort: Pick<RegistrySort, 'key' | 'direction'>;
namespace?: string;
defaultDirection?: RegistrySortDirection;
class?: string;
} = $props();
const active = $derived(sort.key === column);
const nextDirection = $derived<RegistrySortDirection>(
active ? (sort.direction === 'asc' ? 'desc' : 'asc') : defaultDirection
);
const sortKey = $derived(namespace ? `${namespace}Sort` : 'sort');
const directionKey = $derived(namespace ? `${namespace}Direction` : 'direction');
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
const href = $derived.by(() => {
const params = new URLSearchParams(page.url.searchParams);
params.set(sortKey, column);
params.set(directionKey, nextDirection);
params.delete(pageKey);
const query = params.toString();
return `${page.url.pathname}${query ? `?${query}` : ''}`;
});
</script>
<a
class="sortable-column {className}"
class:active
{href}
role="columnheader"
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
aria-label={`${label}: ${active ? `sorted ${sort.direction}. Activate to sort ${nextDirection}` : `activate to sort ${nextDirection}`}`}
>
<span class="sort-direction" aria-hidden="true">{active ? (sort.direction === 'asc' ? '↑' : '↓') : '↕'}</span>
<span>{label}</span>
</a>
@@ -0,0 +1,101 @@
<script lang="ts">
import SearchableAssignment from './SearchableAssignment.svelte';
import SegmentedChoice from './SegmentedChoice.svelte';
type RegistryItem = { id: string; name: string; pending?: boolean; proposerName?: string };
export type StructuredEffect = {
type: 'Attribute' | 'Ability' | 'Custom';
statId: string;
abilityId: string;
label: string;
value: number | string | null;
unit: string;
};
let {
effects = $bindable([]),
attributes,
abilities = [],
idPrefix = 'effect',
emptyLabel = 'NO STRUCTURED EFFECTS',
addLabel = 'ADD EFFECT',
allowTargetLabelFallback = false
}: {
effects?: StructuredEffect[];
attributes: RegistryItem[];
abilities?: RegistryItem[];
idPrefix?: string;
emptyLabel?: string;
addLabel?: string;
allowTargetLabelFallback?: boolean;
} = $props();
const effectTypes = $derived([
{ value: 'Attribute', label: 'ATTRIBUTE' },
...(abilities.length ? [{ value: 'Ability', label: 'ABILITY' }] : []),
{ value: 'Custom', label: 'CUSTOM' }
]);
const units = [
{ value: '', label: 'NONE' },
{ value: '%', label: '%' },
{ value: 's', label: 'SECONDS' },
{ value: 'Shots', label: 'SHOTS' },
{ value: 'm', label: 'METERS' }
];
const blank = (): StructuredEffect => ({
type: 'Custom',
statId: '',
abilityId: '',
label: '',
value: '',
unit: ''
});
const changeType = (index: number, type: string) => {
effects[index] = {
...effects[index],
type: type as StructuredEffect['type'],
statId: type === 'Attribute' ? effects[index].statId : '',
abilityId: type === 'Ability' ? effects[index].abilityId : ''
};
};
</script>
<div class="structured-effect-editor">
{#each effects as effect, index}
{@const labelCanUseTarget = allowTargetLabelFallback && ['Attribute', 'Ability'].includes(effect.type)}
<section class="structured-effect-row">
<header><strong>EFFECT {String(index + 1).padStart(2, '0')}</strong><button type="button" class="danger-link" onclick={() => effects = effects.filter((_, itemIndex) => itemIndex !== index)}>REMOVE</button></header>
<div class="structured-effect-grid">
<SegmentedChoice
legend="EFFECT TYPE"
name={`${idPrefix}-type-${index}`}
options={effectTypes}
bind:value={effect.type}
onchange={(values) => changeType(index, values[0])}
required
/>
{#if effect.type === 'Attribute'}
<div class="field"><span>ATTRIBUTE TARGET</span><SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={effect.statId}/></div>
{:else if effect.type === 'Ability'}
<div class="field"><span>CLASS ABILITY TARGET</span><SearchableAssignment items={abilities} mode="single" compact subjectLabel="CLASS ABILITY" searchPlaceholder="Search abilities…" bind:value={effect.abilityId}/></div>
{:else}
<div class="effect-custom-target"><span>CUSTOM MECHANIC</span><small>Recorded for display now and calculation support later.</small></div>
{/if}
<label class="field effect-label">
EFFECT LABEL{labelCanUseTarget ? ' · OPTIONAL' : ''}
<input
bind:value={effect.label}
maxlength="100"
placeholder={labelCanUseTarget ? 'Uses the selected target name when blank' : 'e.g. Damage Reduction'}
required={!labelCanUseTarget}
/>
</label>
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></label>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit}/>
</div>
</section>
{:else}
<div class="structured-effect-empty"><strong>{emptyLabel}</strong><span>Add structured values when this mechanic should contribute to calculations or an ability preview.</span></div>
{/each}
<button type="button" class="secondary structured-effect-add" onclick={() => effects = [...effects, blank()]}>{addLabel} +</button>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
export type CombatProfile = {
encounterSeconds: number;
hitRate: number;
headshotRate: number;
firingActivity: number;
};
let {
profile = $bindable<CombatProfile>(),
disabled = false
}: {
profile: CombatProfile;
disabled?: boolean;
} = $props();
function update(key: keyof CombatProfile, value: number) {
profile = { ...profile, [key]: value };
}
</script>
<section class="combat-assumptions" aria-labelledby="combat-assumptions-title">
<div class="section-head">
<div>
<p class="eyebrow">COMBAT PROFILE</p>
<h3 id="combat-assumptions-title">Encounter assumptions</h3>
</div>
<small>MODELED, NOT LIVE COMBAT</small>
</div>
<p class="supporting">These assumptions drive encounter-average calculations. Individual effects can override them when needed.</p>
<fieldset class="assumption-grid" disabled={disabled}>
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
<RangeValueControl label="Firing activity" min={0} max={100} value={profile.firingActivity} onchange={(value) => update('firingActivity', value)} />
</fieldset>
</section>
<style>
.combat-assumptions{padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}
.section-head{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md)}
.eyebrow{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.3px}
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
.section-head small{padding:4px 6px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;white-space:nowrap}
.supporting{margin:var(--shd-gap-sm) 0 var(--shd-gap-md);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.assumption-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);margin:0;padding:0;border:0}.assumption-grid:disabled{opacity:.55}
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import type { CombatProfile } from './CombatAssumptions.svelte';
export type EffectMode = 'off' | 'peak' | 'encounter';
export type ConditionalEffectState = {
mode: EffectMode;
overrides?: Partial<CombatProfile>;
};
let {
id,
name,
description,
facts,
appliedState,
effectState = $bindable<ConditionalEffectState>(),
disabled = false
}: {
id: string;
name: string;
description?: string;
facts: string[];
appliedState: string;
effectState: ConditionalEffectState;
disabled?: boolean;
} = $props();
let advanced = $state(false);
const modes = [
{ value: 'off', label: 'Off' },
{ value: 'peak', label: 'Peak' },
{ value: 'encounter', label: 'Encounter' }
];
function setMode(values: string[]) {
effectState = { ...effectState, mode: values[0] as EffectMode };
}
function updateOverride(key: keyof CombatProfile, value: number) {
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
}
function clearOverrides() {
effectState = { ...effectState, overrides: undefined };
}
</script>
<article class:disabled class="conditional-effect" aria-labelledby={`${id}-title`}>
<header>
<div>
<p>CONDITIONAL EFFECT</p>
<h3 id={`${id}-title`}>{name}</h3>
{#if description}<span>{description}</span>{/if}
</div>
<b class:active={effectState.mode !== 'off'}>{appliedState}</b>
</header>
<div class="facts" aria-label={`${name} facts`}>
{#each facts as fact}<span>{fact}</span>{/each}
</div>
<fieldset class="mode-control" disabled={disabled}>
<SegmentedChoice legend={`${name} calculation mode`} name={`${id}-mode`} options={modes} value={effectState.mode} columns={3} onchange={setMode} />
</fieldset>
{#if effectState.mode === 'encounter'}
<div class="advanced-head">
<button type="button" aria-expanded={advanced} aria-controls={`${id}-overrides`} onclick={() => advanced = !advanced} disabled={disabled}>
{advanced ? 'HIDE' : 'ADVANCED'} OVERRIDES <span aria-hidden="true">{advanced ? '−' : '+'}</span>
</button>
{#if effectState.overrides}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
</div>
{#if advanced}
<div class="override-grid" id={`${id}-overrides`}>
<RangeValueControl label="Hit rate" min={0} max={100} value={effectState.overrides?.hitRate ?? 100} onchange={(value) => updateOverride('hitRate', value)} />
<RangeValueControl label="Firing activity" min={0} max={100} value={effectState.overrides?.firingActivity ?? 100} onchange={(value) => updateOverride('firingActivity', value)} />
</div>
{/if}
{/if}
</article>
<style>
.conditional-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border:1px solid var(--shd-border);background:var(--shd-surface)}
.conditional-effect.disabled{opacity:.55;pointer-events:none}
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
header span{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
header>b{flex:0 0 auto;padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
header>b.active{border-color:#74552a;color:var(--shd-orange);background:#1d180e}
.facts{display:flex;flex-wrap:wrap;gap:5px}.facts span{padding:4px 6px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.mode-control{margin:0;padding:0;border:0}.advanced-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);border-top:1px solid var(--shd-border-soft);padding-top:var(--shd-gap-sm)}
button{min-height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);padding:0 10px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
button:hover,button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}button:active{transform:translateY(1px);filter:brightness(.88)}button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.clear{border-color:#563734;color:var(--shd-danger)}.clear:hover,.clear:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}
.override-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid var(--shd-border-soft);background:var(--shd-surface-raised)}
@media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}button{min-height:44px}}
@media(prefers-reduced-motion:reduce){button{transition:none}}
</style>
@@ -0,0 +1,72 @@
<script lang="ts">
export type WeaponCalculationResult = {
weaponName: string;
weaponLabel: string;
status: 'complete' | 'insufficient';
bodyHit?: number;
criticalBodyHit?: number;
headshot?: number;
criticalHeadshot?: number;
burstDps?: number;
sustainedDps?: number;
encounterDps?: number;
effectSummaries?: Array<{
id: string;
name: string;
mode: 'off' | 'peak' | 'encounter';
averageStacks: number;
maxStacksReached: number;
maxStacks: number;
stacksAtEnd: number;
buildUpSeconds: number | null;
maintained: boolean;
}>;
missing?: string[];
revisionLabel?: string;
};
let { result }: { result: WeaponCalculationResult } = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); }
</script>
<article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite">
<header>
<div><p>{result.weaponLabel}</p><h3 id={`${result.weaponLabel}-calculation`}>{result.weaponName}</h3></div>
<b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header>
{#if result.status === 'insufficient'}
<div class="missing"><strong>Calculation unavailable</strong><span>Complete the following required data:</span><ul>{#each result.missing ?? [] as item}<li>{item}</li>{/each}</ul></div>
{:else}
<div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}>
<span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span>
<span><small>HEADSHOT</small><b>{format(result.headshot)}</b></span><span><small>CRITICAL HEADSHOT</small><b>{format(result.criticalHeadshot)}</b></span>
</div>
<div class="dps-grid"><span><small>BURST DPS</small><b>{format(result.burstDps)}</b></span><span><small>SUSTAINED DPS</small><b>{format(result.sustainedDps)}</b></span><span><small>ENCOUNTER DPS</small><b>{format(result.encounterDps)}</b></span></div>
{#if result.effectSummaries?.length}
<div class="stack-telemetry">
<header><span>CONDITIONAL STACK TELEMETRY</span><b>{result.effectSummaries.length} EFFECTS</b></header>
{#each result.effectSummaries as effect}
<div>
<strong>{effect.name}</strong>
<span>{effect.mode.toUpperCase()}</span>
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</small>
<em>{effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`}</em>
<b class:maintained={effect.maintained}>{effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'}</b>
</div>
{/each}
</div>
{/if}
{/if}
<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>
</article>
<style>
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
</style>
+8 -8
View File
@@ -1,12 +1,12 @@
export const databaseCategories = [
{ slug: 'gear', label: 'Gear', code: 'GEAR', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', code: 'WPN', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', code: 'WMOD', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', code: 'GMOD', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', code: 'OSPR', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', code: 'TLNT', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', code: 'BRND', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', code: 'CHIP', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
{ slug: 'gear', label: 'Gear', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
] as const;
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
+1
View File
@@ -0,0 +1 @@
export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg';
+18 -1
View File
@@ -27,7 +27,24 @@ export async function recordIsacAudit(input: {
}
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
if (![
'stat_attributes',
'talents',
'talent_triggers',
'brand_sets',
'gear',
'weapons',
'weapon_mods',
'gear_mods',
'os_protocols',
'skill_chip_sets',
'specializations',
'specialization_focuses',
'specialization_abilities',
'specialization_combat_effects',
'specialization_tree_nodes',
'roles'
].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
+15 -2
View File
@@ -6,11 +6,15 @@ import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
export const DATA_MODULES = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
calculations: {
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' }
},
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
@@ -28,7 +32,16 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' }
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } }
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
}
};
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
+183 -14
View File
@@ -1,15 +1,38 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
export const GAME_ICON_TYPES = [
'talent', 'brand_set', 'os_protocol', 'skill_chip',
'specialization', 'specialization_ability', 'specialization_node'
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip)-[0-9a-f-]{36}\.webp$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations'
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node'
};
export class GameDataIconValidationError extends Error {}
@@ -42,7 +65,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
let input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
@@ -55,17 +78,28 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
}
}
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
let metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.');
}
const stats = await image.stats();
if (stats.isOpaque) {
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
let stats = await image.stats();
if (!metadata.hasAlpha || stats.isOpaque) {
input = await removeBackgroundPreview(input);
image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
metadata = await image.metadata();
stats = await image.stats();
}
if (!metadata.hasAlpha || stats.isOpaque) {
throw new GameDataIconValidationError('The processed icon must contain transparent background pixels.');
}
return await image
.rotate()
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
.extend({
top: 2, right: 2, bottom: 2, left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
@@ -75,11 +109,123 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new GameDataIconValidationError(error.message);
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
}
}
export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise<string> {
const normalizeTags = (values: unknown[]) => [...new Set(values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean))].slice(0, 20);
async function libraryMetadata(database: Database, type: GameDataIconType, entityId: string) {
switch (type) {
case 'talent': {
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
WHERE talent.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
}
case 'brand_set': {
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
}
case 'skill_chip': {
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
FROM skill_chip_sets chip
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE chip.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
}
case 'specialization': {
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
FROM specialization_abilities ability
JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE ability.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
specialization.name AS specialization_name
FROM specialization_tree_nodes node
JOIN specialization_focuses focus ON focus.id=node.focus_id
JOIN specializations specialization ON specialization.id=focus.specialization_id
WHERE node.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
}
}
return { name: null, tags: [] };
}
async function uniqueLibraryName(database: Database, preferred: string, type: GameDataIconType, entityId: string) {
for (const candidate of [
preferred,
`${preferred} · ${LIBRARY_LABEL[type]}`,
`${preferred} · ${entityId.slice(0, 8)}`
]) {
const normalized = candidate.slice(0, 80);
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
}
async function registerLibraryIcon(
database: Database,
type: GameDataIconType,
entityId: string,
key: string,
byteSize: number,
updatedAt: string,
actorId: string
) {
const existing = await (await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
)).get(type, entityId);
const metadata = await libraryMetadata(database, type, entityId);
const preferredName = String(metadata.name ?? `${LIBRARY_LABEL[type]} ${entityId.slice(0, 8)}`);
const derivedTags = normalizeTags(['isac', 'game data', type.replaceAll('_', ' '), preferredName, ...metadata.tags]);
if (existing) {
const previousTags = (() => {
try { return JSON.parse(String(existing.tags_json)); } catch { return []; }
})().filter((tag: unknown) => String(tag).toLocaleLowerCase() !== 'vest');
await (await database.prepare(`UPDATE managed_images SET
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256,updated_at=?
WHERE id=?`)).run(
LIBRARY_CATEGORY[type], JSON.stringify(normalizeTags([...previousTags, ...derivedTags])),
key, key, byteSize, updatedAt, existing.id
);
return String(existing.id);
}
const name = await uniqueLibraryName(database, preferredName, type, entityId);
const id = randomUUID();
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(
id, name, LIBRARY_CATEGORY[type], JSON.stringify(derivedTags), 1, 0, key, key, byteSize, 256, 256,
type, entityId, actorId, updatedAt, updatedAt
);
return id;
}
export async function saveGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
output: Buffer,
actorId: string
): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
@@ -88,12 +234,14 @@ export async function saveGameDataIcon(database: Database, type: GameDataIconTyp
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(type, entityId, key, output.length, now);
await registerLibraryIcon(database, type, entityId, key, output.length, now, actorId);
return now;
}
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
await (await database.prepare('DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?')).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
@@ -121,11 +269,32 @@ export async function readIconMutation(form: FormData): Promise<{ output: Buffer
return { output: null, remove };
}
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
export async function applyIconMutation(
database: Database,
type: GameDataIconType,
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string
): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
else {
const icon = await (await database.prepare(
'SELECT storage_key,byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?'
)).get(type, entityId);
if (typeof icon?.storage_key === 'string') {
await registerLibraryIcon(
database, type, entityId, icon.storage_key, Number(icon.byte_size), String(icon.updated_at), actorId
);
}
}
}
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
return await (await database.prepare('SELECT byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId) ?? null;
return await (await database.prepare(`SELECT icon.byte_size,icon.updated_at,
image.id AS library_image_id,image.category AS library_category,image.tags_json AS library_tags_json
FROM game_data_icons icon
LEFT JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
WHERE icon.entity_type=? AND icon.entity_id=?`)).get(type, entityId) ?? null;
}
+115
View File
@@ -0,0 +1,115 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const MANAGED_IMAGE_CATEGORIES = [
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System'
] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export function managedImageStorageDirectory() {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
}
const storagePath = (key: string) => {
if (!KEY_PATTERN.test(key)) throw new Error('Invalid managed image storage key');
return path.join(managedImageStorageDirectory(), key);
};
export function managedImageUrl(id: string, updatedAt?: unknown) {
if (!ID_PATTERN.test(id)) return '';
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/images/library/${id}${version}`;
}
function validateSvg(input: Buffer) {
const source = input.toString('utf8');
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|| /<!ENTITY\b/i.test(source)
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|| /@import\b/i.test(source)
|| /url\(\s*["']?(?!#)/i.test(source)) {
throw new ManagedImageValidationError('SVG files cannot contain scripts, embedded images, or external resources.');
}
}
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) validateSvg(input);
let processedInput = input;
let source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
let metadata = await source.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new ManagedImageValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF image.');
}
const sourceStats = await source.stats();
if (icon && (!metadata.hasAlpha || sourceStats.isOpaque)) {
processedInput = await removeBackgroundPreview(processedInput);
source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
metadata = await source.metadata();
}
let pipeline = source.rotate();
if (metadata.hasAlpha) {
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
}
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
fit: 'inside',
withoutEnlargement: !icon
});
if (icon && forceWhite) {
const normalized = await pipeline
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
for (let offset = 0; offset < normalized.data.length; offset += normalized.info.channels) {
normalized.data[offset] = 255;
normalized.data[offset + 1] = 255;
normalized.data[offset + 2] = 255;
}
pipeline = sharp(normalized.data, {
raw: { width: normalized.info.width, height: normalized.info.height, channels: normalized.info.channels }
});
}
const output = await pipeline.webp({ quality: icon ? 90 : 84, alphaQuality: 100, effort: 5 }).toBuffer();
const result = await sharp(output).metadata();
return { output, width: result.width ?? 0, height: result.height ?? 0 };
} catch (error) {
if (error instanceof ManagedImageValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new ManagedImageValidationError(error.message);
throw new ManagedImageValidationError('That image could not be processed.');
}
}
export async function saveManagedImageFile(id: string, output: Buffer) {
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
const key = `library-${id}.webp`;
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
return key;
}
export async function readManagedImage(database: Database, id: string) {
if (!ID_PATTERN.test(id)) return null;
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
if (typeof row?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(row.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { getPagination, getRegistrySort } from './pagination.js';
const options = {
name: { asc: 'name ASC', desc: 'name DESC' },
updated: { asc: 'updated_at ASC', desc: 'updated_at DESC', defaultDirection: 'desc' as const }
};
describe('getRegistrySort', () => {
it('returns only allowlisted sort expressions', () => {
const url = new URL('https://shd.build/isac/attributes?sort=name&direction=desc');
expect(getRegistrySort(url, options, 'name')).toEqual({
key: 'name',
direction: 'desc',
orderBy: 'name DESC'
});
});
it('falls back for unknown keys and directions', () => {
const url = new URL('https://shd.build/isac/attributes?sort=DROP%20TABLE&direction=sideways');
expect(getRegistrySort(url, options, 'updated')).toEqual({
key: 'updated',
direction: 'desc',
orderBy: 'updated_at DESC'
});
});
it('supports namespaced registry parameters', () => {
const url = new URL('https://shd.build/isac/users/1?loginSort=name&loginDirection=asc');
expect(getRegistrySort(url, options, 'updated', 'desc', 'login')).toMatchObject({
key: 'name',
direction: 'asc'
});
});
});
describe('getPagination', () => {
it('keeps supported row counts and clamps the selected page', () => {
const url = new URL('https://shd.build/isac/attributes?perPage=50&page=9');
expect(getPagination(url, 75)).toMatchObject({ perPage: 50, page: 2, pageCount: 2 });
});
});
+33
View File
@@ -2,6 +2,39 @@ import type { URL } from 'node:url';
export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const;
export type RegistrySortDirection = 'asc' | 'desc';
export type RegistrySort = {
key: string;
direction: RegistrySortDirection;
orderBy: string;
};
export type RegistrySortOptions = Record<
string,
{ asc: string; desc: string; defaultDirection?: RegistrySortDirection }
>;
export function getRegistrySort(
url: URL,
options: RegistrySortOptions,
defaultKey: string,
defaultDirection: RegistrySortDirection = 'asc',
namespace = ''
): RegistrySort {
const sortKey = namespace ? `${namespace}Sort` : 'sort';
const directionKey = namespace ? `${namespace}Direction` : 'direction';
const requestedKey = String(url.searchParams.get(sortKey) ?? '');
const key = Object.hasOwn(options, requestedKey) ? requestedKey : defaultKey;
const option = options[key];
if (!option) throw new Error(`Unknown default registry sort: ${defaultKey}`);
const requestedDirection = String(url.searchParams.get(directionKey) ?? '').toLowerCase();
const fallbackDirection = option.defaultDirection ?? defaultDirection;
const direction: RegistrySortDirection =
requestedDirection === 'asc' || requestedDirection === 'desc'
? requestedDirection
: fallbackDirection;
return { key, direction, orderBy: option[direction] };
}
export function getRegistrySearch(url: URL, namespace = '') {
const key = namespace ? `${namespace}Q` : 'q';
const search = String(url.searchParams.get(key) ?? '').normalize('NFKC').trim().slice(0, 80);
+55
View File
@@ -0,0 +1,55 @@
import sharp from 'sharp';
import { recordRemoveBgCharge } from '$lib/server/remove-bg-monitor';
const REMOVE_BG_ENDPOINT = 'https://api.remove.bg/v1.0/removebg';
const REMOVE_BG_PREVIEW_EDGE = 1500;
export class BackgroundRemovalError extends Error {}
function apiMessage(status: number) {
if (status === 400) return 'remove.bg could not identify a removable foreground in that image.';
if (status === 402) return 'remove.bg preview credits are currently unavailable.';
if (status === 403) return 'The remove.bg API key was rejected. Update the server configuration.';
if (status === 429) return 'remove.bg is temporarily rate limited. Wait briefly and try again.';
return 'Background removal is temporarily unavailable. Try again or upload an image with transparency.';
}
export async function removeBackgroundPreview(input: Buffer): Promise<Buffer<ArrayBuffer>> {
const apiKey = process.env.REMOVE_BG_API_KEY?.trim();
if (!apiKey) {
throw new BackgroundRemovalError(
'This icon needs background removal, but remove.bg is not configured. Add REMOVE_BG_API_KEY or upload a transparent image.'
);
}
const prepared = await sharp(input, { failOn: 'warning' })
.rotate()
.resize(REMOVE_BG_PREVIEW_EDGE, REMOVE_BG_PREVIEW_EDGE, { fit: 'inside', withoutEnlargement: true })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
const form = new FormData();
form.set('size', 'preview');
form.set('format', 'png');
form.set('image_file', new File([new Uint8Array(prepared)], 'icon-source.png', { type: 'image/png' }));
let response: Response;
try {
response = await fetch(REMOVE_BG_ENDPOINT, {
method: 'POST',
headers: { 'X-Api-Key': apiKey },
body: form,
signal: AbortSignal.timeout(45_000)
});
} catch {
throw new BackgroundRemovalError('remove.bg could not be reached. Try again or upload a transparent image.');
}
if (!response.ok) throw new BackgroundRemovalError(apiMessage(response.status));
const charged = Number(response.headers.get('X-Credits-Charged'));
if (Number.isFinite(charged) && charged > 0) {
await recordRemoveBgCharge(charged).catch((error) => {
console.error('Unable to update the cached remove.bg credit balance.', error);
});
}
const output = Buffer.from(await response.arrayBuffer());
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
return output;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { parseRemoveBgAccount } from './remove-bg-monitor';
describe('parseRemoveBgAccount', () => {
it('normalizes the official account response fields', () => {
expect(parseRemoveBgAccount({
data: {
attributes: {
credits: {
total: 23.5,
subscription: 20,
payg: 3.5,
enterprise: 0
},
api: {
free_calls: 17,
sizes: 'preview'
}
}
}
})).toEqual({
total: 23.5,
subscription: 20,
payg: 3.5,
enterprise: 0,
freeCalls: 17
});
});
it('floors fractional free-call balances and accepts numeric API strings', () => {
expect(parseRemoveBgAccount({
data: {
attributes: {
credits: { total: '7.25', subscription: '5', payg: '2.25' },
api: { free_calls: '4.9' }
}
}
})).toEqual({
total: 7.25,
subscription: 5,
payg: 2.25,
enterprise: 0,
freeCalls: 4
});
});
it.each([
undefined,
{},
{ data: { attributes: {} } },
{ data: { attributes: { credits: { total: 'unknown' }, api: { free_calls: 10 } } } },
{ data: { attributes: { credits: { total: 10 }, api: { free_calls: 'unknown' } } } },
{ data: { attributes: { credits: { total: -1 }, api: { free_calls: 10 } } } },
{ data: { attributes: { credits: { total: 10 }, api: { free_calls: -1 } } } }
])('rejects a malformed or negative required balance: %j', (payload) => {
expect(() => parseRemoveBgAccount(payload)).toThrow('remove.bg returned an invalid account balance.');
});
it('defaults malformed or negative optional credit buckets to zero', () => {
expect(parseRemoveBgAccount({
data: {
attributes: {
credits: {
total: 12,
subscription: -1,
payg: 'not-a-number',
enterprise: Number.POSITIVE_INFINITY
},
api: { free_calls: 8 }
}
}
})).toEqual({
total: 12,
subscription: 0,
payg: 0,
enterprise: 0,
freeCalls: 8
});
});
});
+236
View File
@@ -0,0 +1,236 @@
import { randomUUID } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { getDatabase } from '$lib/server/database';
import { createNotification } from '$lib/server/notifications';
const PROVIDER = 'remove_bg';
const ACCOUNT_ENDPOINT = 'https://api.remove.bg/v1.0/account';
export type RemoveBgCreditStatus = {
configured: boolean;
total: number | null;
subscription: number | null;
payg: number | null;
enterprise: number | null;
freeCalls: number | null;
checkedAt: string | null;
nextCheckAt: string | null;
lastCharge: number | null;
lastChargeAt: string | null;
error: string | null;
severity: 'unknown' | 'ok' | 'warning' | 'critical';
warningCreditThreshold: number;
warningFreeCallThreshold: number;
};
const finiteNonNegative = (value: unknown) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
export function parseRemoveBgAccount(payload: unknown) {
const attributes = (payload as { data?: { attributes?: Record<string, unknown> } })?.data?.attributes;
const credits = attributes?.credits as Record<string, unknown> | undefined;
const api = attributes?.api as Record<string, unknown> | undefined;
const total = finiteNonNegative(credits?.total);
const freeCalls = finiteNonNegative(api?.free_calls);
if (total === null || freeCalls === null) throw new Error('remove.bg returned an invalid account balance.');
return {
total,
subscription: finiteNonNegative(credits?.subscription) ?? 0,
payg: finiteNonNegative(credits?.payg) ?? 0,
enterprise: finiteNonNegative(credits?.enterprise) ?? 0,
freeCalls: Math.floor(freeCalls)
};
}
function settings() {
const warningCreditThreshold = finiteNonNegative(env.REMOVE_BG_LOW_CREDIT_THRESHOLD) ?? 5;
const warningFreeCallThreshold = finiteNonNegative(env.REMOVE_BG_LOW_FREE_CALL_THRESHOLD) ?? 10;
const checkHours = Math.max(1, finiteNonNegative(env.REMOVE_BG_CREDIT_CHECK_HOURS) ?? 6);
return { warningCreditThreshold, warningFreeCallThreshold, checkHours };
}
function severity(total: number, freeCalls: number) {
const { warningCreditThreshold, warningFreeCallThreshold } = settings();
if (total <= 0 && freeCalls <= 0) return 'critical' as const;
if (total <= warningCreditThreshold && freeCalls <= warningFreeCallThreshold) return 'warning' as const;
return 'ok' as const;
}
async function activeAdministrators() {
const database = await getDatabase();
return (await database.prepare(`
SELECT DISTINCT users.id
FROM users
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN user_roles ON user_roles.user_id=users.id
LEFT JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
WHERE deleted.user_id IS NULL AND status.blocked_at IS NULL
AND (status.banned_until IS NULL OR status.banned_until<=?)
AND (users.role='admin' OR user_roles.role_id='role-admin'
OR role_permission.name='system.admin' OR direct_permission.name='system.admin')
`)).all(new Date().toISOString());
}
async function queueAlertEpisode(episodeId: string, alertSeverity: 'warning' | 'critical') {
const database = await getDatabase();
const now = new Date().toISOString();
for (const administrator of await activeAdministrators()) {
await (await database.prepare(`INSERT OR IGNORE INTO external_service_alert_deliveries
(id,provider,episode_id,severity,user_id,created_at)
VALUES(?,?,?,?,?,?)`)).run(
randomUUID(), PROVIDER, episodeId, alertSeverity, administrator.id, now
);
}
}
async function persistBalance(balance: ReturnType<typeof parseRemoveBgAccount>) {
const database = await getDatabase();
const previous = await (await database.prepare(
'SELECT severity,episode_id FROM external_service_status WHERE provider=?'
)).get(PROVIDER);
const now = new Date();
const nextCheck = new Date(now.getTime() + settings().checkHours * 60 * 60_000);
const nextSeverity = severity(balance.total, balance.freeCalls);
const previousSeverity = String(previous?.severity ?? 'unknown');
const recovered = nextSeverity === 'ok' && (previousSeverity === 'warning' || previousSeverity === 'critical');
const startsEpisode = (nextSeverity === 'warning' || nextSeverity === 'critical') &&
(previousSeverity === 'unknown' || previousSeverity === 'ok');
const episodeId = startsEpisode ? randomUUID() : String(previous?.episode_id ?? '') || null;
await (await database.prepare(`INSERT INTO external_service_status(
provider,credits_total,credits_subscription,credits_payg,credits_enterprise,free_calls,
checked_at,next_check_at,error_message,severity,episode_id,episode_started_at,recovered_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(provider) DO UPDATE SET
credits_total=excluded.credits_total,credits_subscription=excluded.credits_subscription,
credits_payg=excluded.credits_payg,credits_enterprise=excluded.credits_enterprise,
free_calls=excluded.free_calls,checked_at=excluded.checked_at,next_check_at=excluded.next_check_at,
error_message=NULL,severity=excluded.severity,episode_id=excluded.episode_id,
episode_started_at=CASE WHEN excluded.episode_id<>external_service_status.episode_id THEN excluded.episode_started_at ELSE external_service_status.episode_started_at END,
recovered_at=excluded.recovered_at,updated_at=excluded.updated_at`)).run(
PROVIDER, balance.total, balance.subscription, balance.payg, balance.enterprise, balance.freeCalls,
now.toISOString(), nextCheck.toISOString(), null, nextSeverity,
recovered ? null : episodeId, startsEpisode ? now.toISOString() : null,
recovered ? now.toISOString() : null, now.toISOString()
);
if (episodeId && (startsEpisode || (previousSeverity === 'warning' && nextSeverity === 'critical'))) {
await queueAlertEpisode(episodeId, nextSeverity);
}
}
export async function refreshRemoveBgCredits(force = false) {
const database = await getDatabase();
const existing = await (await database.prepare(
'SELECT next_check_at FROM external_service_status WHERE provider=?'
)).get(PROVIDER);
if (!force && existing?.next_check_at && String(existing.next_check_at) > new Date().toISOString()) {
return getRemoveBgCreditStatus();
}
const apiKey = env.REMOVE_BG_API_KEY?.trim();
if (!apiKey) return getRemoveBgCreditStatus();
try {
const response = await fetch(ACCOUNT_ENDPOINT, {
headers: { 'X-Api-Key': apiKey },
signal: AbortSignal.timeout(15_000)
});
if (!response.ok) throw new Error(`remove.bg account check failed with status ${response.status}.`);
await persistBalance(parseRemoveBgAccount(await response.json()));
} catch (error) {
const now = new Date();
const retryAt = new Date(now.getTime() + 60 * 60_000);
const message = error instanceof Error ? error.message : 'remove.bg account check failed.';
await (await database.prepare(`INSERT INTO external_service_status(provider,next_check_at,error_message,updated_at)
VALUES(?,?,?,?) ON CONFLICT(provider) DO UPDATE SET
next_check_at=excluded.next_check_at,error_message=excluded.error_message,updated_at=excluded.updated_at`))
.run(PROVIDER, retryAt.toISOString(), message.slice(0, 240), now.toISOString());
}
return getRemoveBgCreditStatus();
}
export async function recordRemoveBgCharge(charged: number) {
if (!Number.isFinite(charged) || charged <= 0) return;
const database = await getDatabase();
const row = await (await database.prepare(
'SELECT credits_total,free_calls,severity,episode_id FROM external_service_status WHERE provider=?'
)).get(PROVIDER);
if (row?.credits_total === null || row?.credits_total === undefined) return;
const now = new Date().toISOString();
const total = Math.max(0, Number(row.credits_total) - charged);
const nextSeverity = severity(total, Number(row.free_calls ?? 0));
const previousSeverity = String(row.severity ?? 'unknown');
const startsEpisode = (nextSeverity === 'warning' || nextSeverity === 'critical') &&
(previousSeverity === 'unknown' || previousSeverity === 'ok');
const episodeId = startsEpisode ? randomUUID() : String(row.episode_id ?? '') || null;
await (await database.prepare(`UPDATE external_service_status SET credits_total=?,last_charge=?,last_charge_at=?,
severity=?,episode_id=?,episode_started_at=CASE WHEN ? THEN ? ELSE episode_started_at END,updated_at=?
WHERE provider=?`)).run(
total, charged, now, nextSeverity, episodeId, startsEpisode ? 1 : 0, now, now, PROVIDER
);
if (episodeId && (startsEpisode || (previousSeverity === 'warning' && nextSeverity === 'critical'))) {
await queueAlertEpisode(episodeId, nextSeverity);
}
}
export async function deliverRemoveBgCreditAlerts() {
const database = await getDatabase();
const pending = await (await database.prepare(`SELECT delivery.*,status.credits_total,status.free_calls
FROM external_service_alert_deliveries delivery
JOIN external_service_status status ON status.provider=delivery.provider
WHERE delivery.delivered_at IS NULL ORDER BY delivery.created_at LIMIT 100`)).all();
let delivered = 0;
for (const alert of pending) {
const title = alert.severity === 'critical'
? 'remove.bg credits exhausted'
: 'remove.bg credits running low';
const body = `remove.bg has ${Number(alert.credits_total ?? 0).toLocaleString()} paid credits and ${Number(alert.free_calls ?? 0).toLocaleString()} free calls remaining. Review image-processing capacity in System Metrics.`;
const notificationId = await createNotification({
userId: String(alert.user_id),
category: 'isac',
source: 'ISAC SYSTEM OVERSIGHT',
title,
body,
link: '/isac/metrics',
actions: [{ label: 'VIEW SYSTEM METRICS', href: '/isac/metrics' }],
mandatory: true
});
await (await database.prepare(`UPDATE external_service_alert_deliveries
SET notification_id=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`))
.run(notificationId, new Date().toISOString(), alert.id);
delivered++;
}
return delivered;
}
export async function getRemoveBgCreditStatus(): Promise<RemoveBgCreditStatus> {
const database = await getDatabase();
const row = await (await database.prepare('SELECT * FROM external_service_status WHERE provider=?')).get(PROVIDER);
const { warningCreditThreshold, warningFreeCallThreshold } = settings();
return {
configured: Boolean(env.REMOVE_BG_API_KEY?.trim()),
total: finiteNonNegative(row?.credits_total),
subscription: finiteNonNegative(row?.credits_subscription),
payg: finiteNonNegative(row?.credits_payg),
enterprise: finiteNonNegative(row?.credits_enterprise),
freeCalls: finiteNonNegative(row?.free_calls),
checkedAt: row?.checked_at ? String(row.checked_at) : null,
nextCheckAt: row?.next_check_at ? String(row.next_check_at) : null,
lastCharge: finiteNonNegative(row?.last_charge),
lastChargeAt: row?.last_charge_at ? String(row.last_charge_at) : null,
error: row?.error_message ? String(row.error_message) : null,
severity: (['ok', 'warning', 'critical'].includes(String(row?.severity))
? String(row?.severity)
: 'unknown') as RemoveBgCreditStatus['severity'],
warningCreditThreshold,
warningFreeCallThreshold
};
}
export async function runRemoveBgCreditMonitor() {
await refreshRemoveBgCredits(false);
return deliverRemoveBgCreditAlerts();
}
+4 -2
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { getDatabase } from '$lib/server/database';
import nodemailer from 'nodemailer';
import { runRemoveBgCreditMonitor } from '$lib/server/remove-bg-monitor';
function mailTransport() {
if (!env.SMTP_HOST || !env.SMTP_FROM) return null;
@@ -159,14 +160,15 @@ async function deliverDailyDigests() {
return sent;
}
let running: Promise<{ system: number; oversight: number; digests: number }> | null = null;
let running: Promise<{ system: number; oversight: number; digests: number; serviceAlerts: number }> | null = null;
export function runScheduledNotificationTasks() {
if (running) return running;
running = (async () => {
const system = await deliverSystemMessages();
const oversight = await deliverOversightMessages();
const digests = await deliverDailyDigests();
return { system, oversight, digests };
const serviceAlerts = await runRemoveBgCreditMonitor();
return { system, oversight, digests, serviceAlerts };
})()
.finally(() => { running = null; });
return running;
+187
View File
@@ -0,0 +1,187 @@
import type { Database } from '@tursodatabase/database';
export async function loadSharedBuildCombatCatalog(database: Database) {
const [
attributes,
gear,
gearAugments,
brands,
brandEffects,
weapons,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
chipSets,
chipTwoPiece,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
activeCalculationRevision,
calculationStages,
calculationProfiles
] = await Promise.all([
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
JOIN calculation_revisions revision ON revision.id=stage.revision_id
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
]);
const configuredTalents = talents.map((talent) => ({
...talent,
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
}))
}));
return {
attributes,
gear,
gearAugments,
brands: brands.map((brand) => ({
...brand,
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
weapons: weapons.map((weapon) => ({
...weapon,
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
})),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
return {
...set,
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
talent: talent
? {
...talent,
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id)
}
: null
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find(
(item) =>
item.quality === protocol.quality
&& item.primary_attribute_type === protocol.primary_attribute_type
);
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryEffects: profile
? protocolSecondary.filter((effect) => effect.profile_id === profile.id)
: [],
talent: talent
? {
...talent,
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id)
}
: null
};
}),
specializations: specializations.map((specialization) => ({
...specialization,
focuses: specializationFocuses
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => {
const abilities = focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
.map((link) => specializationAbilities.find((ability) => ability.id === link.ability_id))
.filter(Boolean);
const nodes = specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
...node,
ranks: specializationRanks
.filter((rank) => rank.node_id === node.id)
.map((rank) => ({
...rank,
effects: specializationRankEffects.filter(
(effect) =>
effect.node_id === node.id
&& Number(effect.rank) === Number(rank.rank)
),
combatEffects: specializationRankCombatEffects
.filter(
(effect) =>
effect.node_id === node.id
&& Number(effect.rank) === Number(rank.rank)
)
.map((effect) => ({
...effect,
definition: specializationCombatEffects.find(
(definition) => definition.id === effect.combat_effect_id
),
modifiers: specializationCombatModifiers.filter(
(modifier) => modifier.rank_combat_effect_id === effect.id
)
}))
}))
}));
return { ...focus, abilities, nodes };
})
})),
calculation: {
revision: activeCalculationRevision ?? null,
stages: calculationStages,
profiles: calculationProfiles
}
};
}
+197
View File
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const databaseRow = vi.hoisted(() => ({ value: undefined as Record<string, unknown> | undefined }));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async () => ({
get: vi.fn(async () => databaseRow.value)
}))
}))
}));
import {
getSiteAccessBlock,
getSiteAccessSettings,
invalidateSiteAccessSettings,
isLaunchGateExempt,
siteSectionForPath,
type SiteAccessSettings,
type SiteSectionKey
} from './site-access';
function accessSettings(
overrides: Partial<Pick<SiteAccessSettings, 'comingSoonEnabled' | 'accountCreationEnabled'>> & {
sections?: Partial<SiteAccessSettings['sections']>;
} = {}
): SiteAccessSettings {
return {
comingSoonEnabled: overrides.comingSoonEnabled ?? false,
accountCreationEnabled: overrides.accountCreationEnabled ?? true,
sections: {
news: true,
database: true,
builds: true,
guides: true,
builder: true,
...overrides.sections
},
updatedAt: '2026-07-27T00:00:00.000Z'
};
}
describe('siteSectionForPath', () => {
it.each([
['/news', 'news'],
['/news/latest', 'news'],
['/database', 'database'],
['/database/weapons/first-wave-vector', 'database'],
['/builds', 'builds'],
['/builds/shared-loadout', 'builds'],
['/guides', 'guides'],
['/guides/combat-medic', 'guides'],
['/builder', 'builder'],
['/builder/loadout-id', 'builder']
])('maps %s to the %s launch section', (pathname, section) => {
expect(siteSectionForPath(pathname)).toBe(section);
});
it.each(['/', '/newsroom', '/database-tools', '/build', '/guide', '/builder-tools', '/isac'])(
'does not treat %s as a gated section',
(pathname) => {
expect(siteSectionForPath(pathname)).toBeNull();
}
);
});
describe('isLaunchGateExempt', () => {
it.each([
'/coming-soon',
'/sign-in',
'/terms',
'/dmca',
'/contact',
'/auth/discord',
'/auth/discord/callback',
'/api/health/database',
'/api/internal/notifications',
'/_app/immutable/entry/start.js',
'/favicon.svg'
])('keeps %s reachable while the launch gate is active', (pathname) => {
expect(isLaunchGateExempt(pathname)).toBe(true);
});
it.each([
'/',
'/news',
'/isac',
'/coming-soon/preview',
'/sign-in/help',
'/auth',
'/api/health',
'/api/internal',
'/favicon.png'
])('does not exempt %s', (pathname) => {
expect(isLaunchGateExempt(pathname)).toBe(false);
});
});
describe('getSiteAccessBlock', () => {
it('gives the global launch gate priority over a disabled section for anonymous visitors', () => {
const settings = accessSettings({
comingSoonEnabled: true,
sections: { builder: false }
});
expect(getSiteAccessBlock(settings, '/builder', false)).toEqual({ kind: 'launch' });
});
it('lets authenticated users bypass the global launch gate when the requested section is enabled', () => {
const settings = accessSettings({ comingSoonEnabled: true });
expect(getSiteAccessBlock(settings, '/builder', true)).toBeNull();
});
it.each([
['/news', 'news'],
['/database/weapons', 'database'],
['/builds/agent-loadout', 'builds'],
['/guides/combat-medic', 'guides'],
['/builder', 'builder']
] satisfies Array<[string, SiteSectionKey]>)(
'blocks %s for authenticated and anonymous visitors when the %s section is disabled',
(pathname, section) => {
const settings = accessSettings({
sections: { [section]: false }
});
expect(getSiteAccessBlock(settings, pathname, false)).toEqual({ kind: 'section', section });
expect(getSiteAccessBlock(settings, pathname, true)).toEqual({ kind: 'section', section });
}
);
it.each(['/coming-soon', '/sign-in', '/terms', '/dmca', '/contact', '/auth/discord/callback'])(
'keeps exempt route %s available while the global gate is active',
(pathname) => {
const settings = accessSettings({ comingSoonEnabled: true });
expect(getSiteAccessBlock(settings, pathname, false)).toBeNull();
expect(getSiteAccessBlock(settings, pathname, true)).toBeNull();
}
);
it('allows an enabled section when the global launch gate is off', () => {
const settings = accessSettings();
expect(getSiteAccessBlock(settings, '/news', false)).toBeNull();
expect(getSiteAccessBlock(settings, '/news', true)).toBeNull();
});
});
describe('getSiteAccessSettings', () => {
beforeEach(() => {
databaseRow.value = undefined;
invalidateSiteAccessSettings();
});
it('fails safely to the launch page while leaving registration and sections staged on a new database', async () => {
await expect(getSiteAccessSettings()).resolves.toEqual({
comingSoonEnabled: true,
accountCreationEnabled: true,
sections: {
news: true,
database: true,
builds: true,
guides: true,
builder: true
},
updatedAt: '2026-07-27T00:00:00.000Z'
});
});
it('normalizes persisted integer flags into the public settings shape', async () => {
databaseRow.value = {
coming_soon_enabled: 0,
account_creation_enabled: 0,
news_enabled: 1,
database_enabled: 0,
builds_enabled: 1,
guides_enabled: 0,
builder_enabled: 1,
updated_at: '2026-08-01T12:30:00.000Z'
};
await expect(getSiteAccessSettings()).resolves.toEqual({
comingSoonEnabled: false,
accountCreationEnabled: false,
sections: {
news: true,
database: false,
builds: true,
guides: false,
builder: true
},
updatedAt: '2026-08-01T12:30:00.000Z'
});
});
});
+85
View File
@@ -0,0 +1,85 @@
import { getDatabase } from '$lib/server/database';
export const SITE_SECTION_KEYS = ['news', 'database', 'builds', 'guides', 'builder'] as const;
export type SiteSectionKey = (typeof SITE_SECTION_KEYS)[number];
export type SiteAccessSettings = {
comingSoonEnabled: boolean;
accountCreationEnabled: boolean;
sections: Record<SiteSectionKey, boolean>;
updatedAt: string;
};
export type SiteAccessBlock =
| { kind: 'launch' }
| { kind: 'section'; section: SiteSectionKey };
const DEFAULT_SETTINGS: SiteAccessSettings = {
comingSoonEnabled: true,
accountCreationEnabled: true,
sections: { news: true, database: true, builds: true, guides: true, builder: true },
updatedAt: '2026-07-27T00:00:00.000Z'
};
let cached: { expiresAt: number; value: SiteAccessSettings } | null = null;
function normalized(row: Record<string, unknown> | undefined): SiteAccessSettings {
if (!row) return structuredClone(DEFAULT_SETTINGS);
return {
comingSoonEnabled: Boolean(row.coming_soon_enabled),
accountCreationEnabled: Boolean(row.account_creation_enabled),
sections: {
news: Boolean(row.news_enabled),
database: Boolean(row.database_enabled),
builds: Boolean(row.builds_enabled),
guides: Boolean(row.guides_enabled),
builder: Boolean(row.builder_enabled)
},
updatedAt: String(row.updated_at ?? DEFAULT_SETTINGS.updatedAt)
};
}
export function invalidateSiteAccessSettings() {
cached = null;
}
export async function getSiteAccessSettings() {
if (cached && cached.expiresAt > Date.now()) return cached.value;
const database = await getDatabase();
const row = await (await database.prepare("SELECT * FROM site_launch_settings WHERE id='primary'")).get();
const value = normalized(row);
cached = { value, expiresAt: Date.now() + 5_000 };
return value;
}
export function siteSectionForPath(pathname: string): SiteSectionKey | null {
for (const section of SITE_SECTION_KEYS) {
if (pathname === `/${section}` || pathname.startsWith(`/${section}/`)) return section;
}
return null;
}
export function isLaunchGateExempt(pathname: string) {
return pathname === '/coming-soon' ||
pathname === '/sign-in' ||
pathname === '/terms' ||
pathname === '/dmca' ||
pathname === '/contact' ||
pathname.startsWith('/auth/') ||
pathname.startsWith('/api/health/') ||
pathname.startsWith('/api/internal/') ||
pathname.startsWith('/_app/') ||
pathname === '/favicon.svg';
}
export function getSiteAccessBlock(
settings: SiteAccessSettings,
pathname: string,
authenticated: boolean
): SiteAccessBlock | null {
if (isLaunchGateExempt(pathname)) return null;
if (!authenticated && settings.comingSoonEnabled) return { kind: 'launch' };
const section = siteSectionForPath(pathname);
if (section && !settings.sections[section]) return { kind: 'section', section };
return null;
}
@@ -0,0 +1,59 @@
import type { Database } from '@tursodatabase/database';
export async function refreshSpecializationFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, abilityCount: 0, chipReadyCount: 0 };
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] = await Promise.all([
(await database.prepare("SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)).get(focusId),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3`)).get(focusId),
(await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number'
)).all(focusId),
(await database.prepare('SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank')).all()
]);
const pointForRank = new Map(rankProfiles.map((profile) => [Number(profile.rank), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const chipReadyCount = Number(chipReadyRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& chipReadyCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount, chipReadyCount };
}
export async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function refreshAbilityFocuses(database: Database, abilityId: string) {
if (!abilityId) return;
const focuses = await (await database.prepare(
'SELECT focus_id FROM specialization_focus_abilities WHERE ability_id=?'
)).all(abilityId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.focus_id));
}
+7 -1
View File
@@ -2,8 +2,14 @@ import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import type { LayoutServerLoad } from './$types';
import { notificationSummary } from '$lib/server/notifications';
import { getSiteAccessSettings } from '$lib/server/site-access';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
return { user, isacAccess: await hasPermission(user, 'isac.access'), notifications: user ? await notificationSummary(user.id) : { unread: 0 } };
return {
user,
isacAccess: await hasPermission(user, 'isac.access'),
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
siteAccess: await getSiteAccessSettings()
};
};
+6 -1
View File
@@ -9,6 +9,7 @@
import '../app.css';
import AppShell from '$lib/components/AppShell.svelte';
import ToastViewport from '$lib/components/ToastViewport.svelte';
import { page } from '$app/state';
let { data, children } = $props();
</script>
@@ -16,5 +17,9 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head>
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
{#if page.url.pathname === '/coming-soon'}
{@render children()}
{:else}
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications} siteAccess={data.siteAccess}>{@render children()}</AppShell>
{/if}
<ToastViewport/>
+2 -2
View File
@@ -5,10 +5,10 @@
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' }
];
</script>
<svelte:head><title>SHD.build — The Division Resurgence Community</title><meta name="description" content="The Division Resurgence community for builds, guides, game data, news, and agent discussion." /></svelte:head>
<svelte:head><title>SHD.build — The Division Resurgence Data & Tools</title><meta name="description" content="Builds, guides, game data, news, and agent tools for The Division Resurgence." /></svelte:head>
<main>
<section class="hero">
<div class="hero-copy"><p class="eyebrow"><span></span> THE DIVISION RESURGENCE COMMUNITY</p><h1>CONNECT.<br /><em>ADAPT. RISE.</em></h1><p class="lede">Build stronger agents, learn from field-tested guides, explore the game database, follow Resurgence news, and share intelligence with the community.</p><div class="hero-actions"><a class="primary" href="/builder">CREATE A BUILD <span>+</span></a><a class="secondary" href="/news">ENTER THE NETWORK ↓</a></div><div class="metrics community-metrics"><div><strong>BUILD</strong><span>LOADOUT LAB</span></div><div><strong>LEARN</strong><span>FIELD GUIDES</span></div><div><strong>TRACK</strong><span>DATA & NEWS</span></div></div></div>
<div class="hero-copy"><p class="eyebrow"><span></span> THE DIVISION RESURGENCE DATA & TOOLS</p><h1>CONNECT.<br /><em>ADAPT. RISE.</em></h1><p class="lede">Build stronger agents, learn from field-tested guides, explore the game database, follow Resurgence news, and share intelligence with the community.</p><div class="hero-actions"><a class="primary" href="/builder">CREATE A BUILD <span>+</span></a><a class="secondary" href="/news">ENTER THE NETWORK ↓</a></div><div class="metrics community-metrics"><div><strong>BUILD</strong><span>LOADOUT LAB</span></div><div><strong>LEARN</strong><span>FIELD GUIDES</span></div><div><strong>TRACK</strong><span>DATA & NEWS</span></div></div></div>
<div class="loadout-preview community-preview"><div class="scan-lines"></div><div class="preview-top"><span>COMMUNITY INTELLIGENCE</span><b>SHD // LIVE</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['BUILDS','GUIDES','DATA','NEWS'] as label,i}<a href={['/builds','/guides','/database','/news'][i]} class="gear-node n-{i}"><small>{label}</small><b>{['OPTIMIZE','LEARN','REFERENCE','UPDATES'][i]}</b></a>{/each}</div><div class="preview-name"><small>COMMUNITY // ONLINE</small><strong>RESURGENCE INTELLIGENCE HUB</strong><span>BUILT FOR AGENTS</span></div></div>
</section>
<section class="community-channels">
@@ -1,7 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
@@ -11,30 +11,32 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const filtering = url.searchParams.get('filtering') === '1';
const categories = filtering
? url.searchParams.getAll('channel').filter((value) => NOTIFICATION_CATEGORIES.includes(value as any))
: [...NOTIFICATION_CATEGORIES];
const states = filtering
? url.searchParams.getAll('state').filter((value) => value === 'unread' || value === 'read')
: ['unread', 'read'];
const requestedState = String(url.searchParams.get('state') ?? 'all');
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
if (categories.length === 0 || states.length === 0) {
conditions.push('1=0');
} else {
if (categories.length < NOTIFICATION_CATEGORIES.length) {
conditions.push(`n.category IN (${categories.map(() => '?').join(',')})`);
args.push(...categories);
}
if (states.length === 1) conditions.push(states[0] === 'unread' ? 'n.read_at IS NULL' : 'n.read_at IS NOT NULL');
if (state === 'unread') conditions.push('n.read_at IS NULL');
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
if (search) {
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = {
newest: 'n.created_at DESC, n.id DESC',
oldest: 'n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
}[sort];
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
const permissions = await getPermissions(user);
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
@@ -59,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return { notifications, preferences, oversightPreferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
return { notifications, preferences, oversightPreferences, pagination, filters: { state, sort, search }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
};
export const actions: Actions = {
+466 -15
View File
@@ -4,10 +4,13 @@
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { tick } from 'svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let consumedPreferenceSaveToken = $state<string | null>(null);
let selected = $state<any>(null);
let detailDialog = $state<HTMLDivElement>();
let detailTrigger: HTMLButtonElement | null = null;
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)));
@@ -53,7 +56,43 @@
browserPermission = await Notification.requestPermission();
}
function applyFilters() {
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
document.querySelector<HTMLFormElement>('.notification-query-console')?.requestSubmit();
}
async function openDetails(notification: any, trigger: HTMLButtonElement) {
detailTrigger = trigger;
selected = notification;
await tick();
detailDialog?.querySelector<HTMLButtonElement>('.notification-detail-close')?.focus();
}
async function closeDetails() {
selected = null;
await tick();
detailTrigger?.focus();
}
function handleDetailKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
void closeDetails();
return;
}
if (event.key !== 'Tab') return;
const dialog = detailDialog;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusable.length) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
$effect(() => {
const token = form?.notificationPreferenceSaveToken;
@@ -123,32 +162,73 @@
</section>
{/if}
<div class="notification-toolbar">
<form class="notification-filter-console">
<input type="hidden" name="filtering" value="1"/>
<SegmentedChoice legend="CHANNELS" name="channel" mode="checkbox" options={Object.entries(labels).map(([value, label]) => ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/>
<SegmentedChoice legend="MESSAGE STATE" name="state" mode="checkbox" options={[{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} selected={data.filters.states} onchange={applyFilters}/>
<form class="notification-query-console">
{#if data.filters.search}<input type="hidden" name="q" value={data.filters.search}/>{/if}
<input type="hidden" name="perPage" value={data.pagination.perPage}/>
<SegmentedChoice legend="SHOW" name="state" options={[{value:'all',label:'ALL'},{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} value={data.filters.state} onchange={applyFilters}/>
<SegmentedChoice legend="SORT" name="sort" options={[{value:'newest',label:'NEWEST'},{value:'oldest',label:'OLDEST'},{value:'unread',label:'UNREAD FIRST'}]} value={data.filters.sort} onchange={applyFilters}/>
</form>
{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}
</div>
<RegistryPagination pagination={data.pagination}/>
<RegistryPagination pagination={data.pagination} search={data.filters.search} searchPlaceholder="SEARCH MESSAGES"/>
<section class="notification-list">
{#each data.notifications as notification}
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if storedActions(notification).length}{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}{:else if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
<div>
<div class="notification-message-header">
<div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div>
<div class="notification-message-meta">
<time>{date(notification.created_at)}</time>
{#if !notification.read_at}
<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button class="notification-read-action">MARK READ</button></PendingForm>
{:else}
<span class="notification-read-state">READ</span>
{/if}
</div>
</div>
{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}
<footer>
<div class="notification-actions">
<button class="notification-detail-action" type="button" onclick={(event) => openDetails(notification, event.currentTarget)}>VIEW DETAILS</button>
{#if storedActions(notification).length}
{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}
{:else if action(notification)}
<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>
{/if}
</div>
</footer>
</div>
</article>
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the current search and message state.</p></div>{/each}
</section>
</main>
{#if selected}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title">
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
<div class="form-actions notification-detail-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if storedActions(selected).length}{#each storedActions(selected) as callout}<a class="primary button" href={callout.href}>{callout.label} →</a>{/each}{:else if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDetails(); }}>
<div bind:this={detailDialog} class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title" tabindex="-1" onkeydown={handleDetailKeydown}>
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button class="notification-detail-close" aria-label="Close notification details" onclick={closeDetails}>×</button></div>
<div class="notification-detail-content">
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p class="notification-detail-body">{selected.body}</p>{/if}
{#if storedActions(selected).length || action(selected)}
<section class="notification-detail-callouts" aria-label="Message actions">
<span>AVAILABLE ACTIONS</span>
<div>
{#if storedActions(selected).length}
{#each storedActions(selected) as callout}<a href={callout.href}>{callout.label} →</a>{/each}
{:else if action(selected)}
<a href={action(selected)?.href}>{action(selected)?.label} →</a>
{/if}
</div>
</section>
{/if}
</div>
<footer class="notification-detail-utility">
{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{:else}<span>MESSAGE READ</span>{/if}
<button type="button" class="secondary" onclick={closeDetails}>CLOSE</button>
</footer>
</div>
</div>
{/if}
@@ -168,6 +248,7 @@
.notification-message-header h2 {
margin: 0;
font-size: var(--shd-type-supporting);
line-height: 1.2;
}
@@ -175,4 +256,374 @@
line-height: 1;
white-space: nowrap;
}
.notification-message-header > div:first-child,
.notification-message-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.notification-message-meta {
flex: 0 0 auto;
}
.notification-read-action,
.notification-read-state {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border: 1px solid var(--shd-border-strong);
background: #111516;
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
white-space: nowrap;
}
.notification-read-action {
cursor: pointer;
}
.notification-read-action:hover,
.notification-read-action:focus-visible {
border-color: var(--shd-orange);
background: #1b160d;
color: var(--shd-text);
}
.notification-read-action:active {
transform: translateY(1px);
filter: brightness(.88);
}
.notification-read-state {
color: var(--shd-text-muted);
}
.notification-query-console {
min-width: 0;
flex: 1;
display: grid !important;
grid-template-columns: repeat(2, minmax(260px, 320px));
align-items: end !important;
gap: 8px !important;
}
.notification-query-console > input {
display: none;
}
.notification-query-console :global(.segmented-choice > div) {
height: 36px;
}
.notification-list article p,
.notification-list article :global(.system-message-body > p) {
font-size: var(--shd-type-body);
line-height: 1.6;
}
.notification-list article :global(.system-message-body:not(.expanded) > p),
.notification-list article :global(.system-message-body:not(.expanded) .system-message-detail > p),
.notification-body-preview {
-webkit-line-clamp: 4;
line-clamp: 4;
}
.notification-list article footer {
display: block;
margin-top: 10px;
}
.notification-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
align-items: stretch;
gap: 7px;
}
.notification-actions .notification-detail-action,
.notification-actions .notification-response-action {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
white-space: nowrap;
transition: var(--shd-transition);
}
.notification-actions .notification-detail-action {
border: 1px solid var(--shd-border-strong);
background: #111516;
color: var(--shd-text-muted);
cursor: pointer;
}
.notification-actions .notification-detail-action:hover,
.notification-actions .notification-detail-action:focus-visible {
border-color: var(--shd-text-muted);
background: #181c1d;
color: var(--shd-text);
}
.notification-actions .notification-detail-action:active,
.notification-actions .notification-response-action:active {
transform: translateY(1px);
filter: brightness(.9);
}
.notification-actions .notification-detail-action:focus-visible,
.notification-actions .notification-response-action:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.notification-detail-modal {
width: min(840px, calc(100vw - 24px));
max-height: min(820px, calc(100vh - 32px));
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 0;
overflow: hidden;
}
.notification-detail-sender {
padding: 14px 16px;
}
.notification-detail-sender > .notification-detail-close {
width: 40px;
height: 40px;
border-color: var(--shd-border-strong);
font-size: 18px;
transition: var(--shd-transition);
}
.notification-detail-sender > .notification-detail-close:hover,
.notification-detail-sender > .notification-detail-close:focus-visible {
border-color: var(--shd-orange);
background: #1b160d;
color: var(--shd-orange);
}
.notification-detail-sender > .notification-detail-close:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.notification-detail-sender > .notification-detail-close:active {
transform: translateY(1px);
filter: brightness(.88);
}
.notification-detail-content {
min-height: 0;
overflow-y: auto;
padding: 0 16px 16px;
overscroll-behavior: contain;
scrollbar-color: var(--shd-border-strong) var(--shd-surface);
}
.notification-detail-content h2 {
margin: 18px 0 10px;
font-size: clamp(22px, 3vw, 30px);
line-height: 1.1;
letter-spacing: -.4px;
}
.notification-detail-body {
min-height: 110px;
margin: 0;
padding: 16px;
border-left: 2px solid var(--shd-border-strong);
background: #0e1112;
color: var(--shd-text);
font: var(--shd-type-body)/1.7 var(--shd-font-editorial);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.notification-detail-content :global(.system-message-body.expanded),
.notification-detail-content :global(.system-message-body.expanded p) {
overflow-wrap: anywhere;
}
.notification-detail-callouts {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--shd-border);
}
.notification-detail-callouts > span {
display: block;
margin-bottom: 7px;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: 1px;
}
.notification-detail-callouts > div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
gap: 7px;
}
.notification-detail-callouts a {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border: 1px solid #6b5329;
background: #1b160d;
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
text-align: center;
transition: var(--shd-transition);
}
.notification-detail-callouts a:hover,
.notification-detail-callouts a:focus-visible {
border-color: var(--shd-orange);
background: #241b0d;
color: var(--shd-text);
}
.notification-detail-callouts a:active {
transform: translateY(1px);
filter: brightness(.9);
}
.notification-detail-utility {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 16px;
border-top: 1px solid var(--shd-border);
background: #0d1011;
}
.notification-detail-utility :global(form) {
margin: 0;
}
.notification-detail-utility button {
min-width: 132px;
min-height: 40px;
margin: 0;
}
.notification-detail-utility > span {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .8px;
}
@media (max-width: 1000px) {
.notification-toolbar {
grid-template-columns: 1fr;
}
.notification-query-console {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 700px) {
.notification-query-console {
grid-template-columns: 1fr;
}
.notification-query-console :global(.segmented-choice > div) {
height: auto;
}
.notification-query-console :global(.segmented-choice span) {
min-height: 44px;
}
.notification-message-header {
align-items: stretch;
}
.notification-message-header > div:first-child {
align-items: flex-start;
flex-direction: column;
}
.notification-message-meta {
width: 100%;
justify-content: space-between;
}
.notification-read-action,
.notification-read-state {
min-height: 44px;
}
.notification-actions {
grid-template-columns: 1fr;
}
.notification-actions .notification-detail-action,
.notification-actions .notification-response-action {
min-height: 44px;
}
.notification-detail-modal {
width: calc(100vw - 12px);
max-height: calc(100dvh - 12px);
}
.notification-detail-sender {
grid-template-columns: 40px minmax(0, 1fr) 44px;
padding: 10px;
}
.notification-detail-sender > img {
width: 40px;
height: 40px;
}
.notification-detail-sender > .notification-detail-close {
width: 44px;
height: 44px;
}
.notification-detail-content {
padding: 0 10px 12px;
}
.notification-detail-content h2 {
font-size: 22px;
}
.notification-detail-callouts > div {
grid-template-columns: 1fr;
}
.notification-detail-callouts a,
.notification-detail-utility button {
min-height: 44px;
}
.notification-detail-utility {
padding: 9px 10px;
}
}
</style>
@@ -7,6 +7,7 @@ import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
const STATE_COOKIE = 'shd_discord_oauth_state';
@@ -112,6 +113,9 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
let created = false;
if (!user) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
redirect(303, '/sign-in?oauth_error=registration_closed');
}
const username = await uniqueUsername();
const id = randomUUID();
const now = new Date().toISOString();
+72 -9
View File
@@ -3,6 +3,7 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import type { Actions, PageServerLoad } from './$types';
const validVisibility = new Set(['private', 'unlisted', 'public']);
@@ -12,11 +13,7 @@ const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
function parseLoadout(raw: FormDataEntryValue | null) {
if (typeof raw !== 'string' || raw.length > 250_000) return null;
try {
const value = JSON.parse(raw);
if (value?.version !== 1 || typeof value.buildName !== 'string' || !value.gear ||
!Array.isArray(value.weapons) || value.weapons.length !== 2 ||
!Array.isArray(value.chips) || value.chips.length !== 3 || !value.protocol) return null;
return value;
return parseLoadoutDraft(JSON.parse(raw));
} catch {
return null;
}
@@ -40,7 +37,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talents, talentTiers, talentEffects, talentTemplates,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
icons
icons, specializations, specializationFocuses, specializationAbilities, focusAbilities,
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles
] = await Promise.all([
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
@@ -71,9 +71,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(),
(await database.prepare("SELECT * FROM game_data_icons")).all()
(await database.prepare("SELECT * FROM game_data_icons")).all(),
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
JOIN calculation_revisions revision ON revision.id=stage.revision_id
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
]);
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip' | 'specialization' | 'specialization_ability' | 'specialization_node', id: unknown) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
@@ -134,7 +154,50 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
};
})
}),
specializations: specializations.map((specialization) => ({
...specialization,
iconUrl: iconUrl('specialization', specialization.id),
focuses: specializationFocuses.filter((focus) => focus.specialization_id === specialization.id).map((focus) => {
const assignedAbilities = focusAbilities.filter((link) => link.focus_id === focus.id)
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
.map((link) => {
const ability = specializationAbilities.find((item) => item.id === link.ability_id);
return ability ? {
...ability,
slot: Number(link.ability_slot),
iconUrl: iconUrl('specialization_ability', ability.id)
} : null;
}).filter(Boolean);
const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({
...node,
iconUrl: iconUrl('specialization_node', node.id),
ranks: specializationRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
...rank,
effects: specializationRankEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
costs: specializationRankCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)),
combatEffects: specializationRankCombatEffects
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
.map((effect) => ({
...effect,
definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id),
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
}))
}))
}));
return { ...focus, abilities: assignedAbilities, nodes };
})
})),
specializationProgression: {
...progression,
availablePoints: Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19),
rankProfiles
},
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles
}
};
};
+160 -126
View File
@@ -7,14 +7,19 @@
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => data.initialBuild?.loadout ?? createDraft()));
let draft = $state<LoadoutDraft>(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
let active = $state('gear:Holster');
let saved = $state(false);
let message = $state('');
@@ -32,6 +37,23 @@
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
));
const selectedFocus = $derived(allFocuses.find((focus: any) => focus.id === draft.specialization.focusId));
const specializationItems = $derived(allFocuses.map((focus: any) => ({
id: focus.id,
name: `${focus.specialization.name} · ${focus.name}`,
category: `${focus.primary_attribute} · ${Number(focus.builder_ready) === 1 ? 'BUILDER READY' : 'INCOMPLETE DATA'}`,
iconUrl: focus.specialization.iconUrl
})));
const selectedNodeRanks = $derived(selectedFocus?.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) }] : [];
}) ?? []);
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
));
const talent = (id: string) => data.talents.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
@@ -40,6 +62,19 @@
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const combatEffectDescription = (effect: any) => {
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
return template
.replaceAll('{triggerCount}', String(effect.trigger_count))
.replaceAll('{triggerWindow}', `${effect.trigger_window_seconds}s`)
.replaceAll('{duration}', String(effect.duration_seconds))
.replaceAll('{maxStacks}', String(effect.max_stacks))
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const modifier = effect.modifiers[Number(raw) - 1];
return modifier ? unitValue(modifier.value_per_stack, modifier.stat_attribute_id) : placeholder;
});
};
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
@@ -65,6 +100,70 @@
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
};
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return total + Array.from({ length: selectedRank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
}, 0) ?? 0);
const selectedSpecializationCosts = $derived.by(() => {
let credits = 0;
const resources = new Map<string, number>();
for (const { node, rank } of selectedNodeRanks) {
for (let current = 1; current <= Number(rank.rank); current += 1) {
const profile = data.specializationProgression.rankProfiles.find((item: any) => Number(item.rank) === current);
credits += Number(profile?.credit_cost ?? 0);
const rankRecord = node.ranks.find((item: any) => Number(item.rank) === current);
for (const cost of rankRecord?.costs ?? []) resources.set(String(cost.resource_id), (resources.get(String(cost.resource_id)) ?? 0) + Number(cost.quantity));
}
}
return { credits, resources: [...resources] };
});
const canSelectNodeRank = (node: any, rank: number) => {
if (rank === 0) return true;
const pointsWithoutNode = selectedSpecializationPoints - Array.from(
{ length: Number(draft.specialization.nodeRanks[node.id] ?? 0) },
(_, index) => rankPointCost(index + 1)
).reduce((sum, value) => sum + value, 0);
const candidatePoints = Array.from({ length: rank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
if (pointsWithoutNode + candidatePoints > Number(data.specializationProgression.availablePoints)) return false;
if (Number(node.row_number) <= 1) return true;
return selectedFocus.nodes
.filter((previous: any) => Number(previous.row_number) < Number(node.row_number))
.every((previous: any) => Number(draft.specialization.nodeRanks[previous.id] ?? 0) >= Math.min(2, Number(previous.max_rank)));
};
const setNodeRank = (node: any, rank: number) => {
if (!canSelectNodeRank(node, rank)) return;
draft.specialization.nodeRanks = { ...draft.specialization.nodeRanks, [node.id]: rank };
};
const selectFocus = (focusId: string) => {
const next = allFocuses.find((focus: any) => focus.id === focusId);
if (!next) return;
const displaced: LoadoutDraft['chips'][number][] = [];
for (let index = 0; index < draft.chips.length; index += 1) {
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
draft.chips[targetIndex] = structuredClone(chip);
}
draft.specialization = {
...draft.specialization,
focusId,
nodeRanks: {},
effectStates: {}
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
draft.activeTalents = draft.activeTalents.includes(key)
? draft.activeTalents.filter((item) => item !== key)
@@ -189,129 +288,31 @@
if (value === null || value === undefined || value === '') return '';
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived.by(() => {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(statId), current = values.get(statId) ?? 0, incoming = Number(value);
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(statId, stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_value)));
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
};
const byName = (name: string) => data.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [], equippedTalents: any[] = [];
for (const slot of gearSlots) {
const selection = draft.gear[slot], item = gearItem(slot);
if (!item) continue;
const enabledAugments = data.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, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) });
}
for (const [brandId, count] of brandCounts) {
const set = brand(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++) {
const selection = draft.weapons[index], item = weaponItem(index);
if (!item) continue;
for (const name of ['Damage', 'Firepower', 'Toughness', 'Engineering'] as const) add(byName(name), selection[name.toLowerCase() as 'damage'], `${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, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(talentId, selection.tier)
});
}
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
for (let index = 0; index < 3; index++) {
const chip = draft.chips[index], item = chipItem(index);
if (!item) continue;
add(chip.primary.statId, chip.primary.value, `Skill Chip ${index + 1} primary`);
for (const roll of chip.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 (chip.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, tier: group.highEnd, 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(scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id) : placeholder;
})
});
}
const protocol = protocolItem();
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, 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(Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), effect.stat_attribute_id) : placeholder;
})
});
}
for (const equipped of equippedTalents) {
if (!draft.activeTalents.includes(equipped.key)) continue;
const effects = equipped.custom?.effects ?? talentTier(equipped.id, equipped.tier)?.effects ?? [];
for (const effect of effects) {
const value = equipped.key.startsWith('chips:') ? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
: equipped.key.startsWith('protocol:') ? Number(effect.base_value) * (1 + equipped.tier / 5) : effect.value;
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
}
}
return {
stats: [...values].map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })).sort((a, b) => (attribute(a.id)?.category ?? '').localeCompare(attribute(b.id)?.category ?? '') || (attribute(a.id)?.name ?? '').localeCompare(attribute(b.id)?.name ?? '')),
activeBonuses,
equippedTalents
const summary = $derived(summarizeLoadout(draft, data));
const combatResults = $derived(evaluateLoadoutCombat(draft, data, summary));
$effect(() => {
const missing = selectedCombatEffects.filter((effect: any) => !draft.specialization.effectStates[effect.id]);
if (!missing.length) return;
draft.specialization.effectStates = {
...draft.specialization.effectStates,
...Object.fromEntries(missing.map((effect: any) => [effect.id, { mode: 'off' }]))
};
});
onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v1');
const stored = localStorage.getItem('shd-build-loadout-draft-v2');
if (!stored) return;
try {
const parsed = JSON.parse(stored);
if (parsed?.version === 1) {
const parsed = parseLoadoutDraft(JSON.parse(stored));
if (parsed) {
draft = parsed;
for (const slot of gearSlots) {
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
const item = gearItem(slot), core = coreAllocationRange(Number(item?.tier ?? 0), draft.gear[slot].level, 'gear');
for (const key of coreKeys) if (!Number.isFinite(draft.gear[slot][key])) draft.gear[slot][key] = core.min;
draft.gear[slot].rolls = [...(draft.gear[slot].rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
for (const weapon of draft.weapons) {
if (!Number.isInteger(weapon.level)) weapon.level = 50;
weapon.rolls = [...(weapon.rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
}
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); }
} else localStorage.removeItem('shd-build-loadout-draft-v2');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v2');
}
});
$effect(() => {
if (!form?.buildSaved || !form?.buildToken || form.buildToken === handledBuildToken) return;
@@ -326,7 +327,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -359,26 +360,57 @@
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} showIndicator={false} required/>
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>12 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
<div class="loadout-workspace">
<section class="loadout-slots">
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 12 EQUIPPED</b></header>
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 18 EQUIPPED</b></header>
<div class="loadout-slot-group weapons">{#each ['PRIMARY', 'SECONDARY'] as label, index}{@const item = weaponItem(index)}<button class:active={active === `weapon:${index}`} onclick={() => active = `weapon:${index}`}><small>{label} WEAPON</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category}${Number(item.is_exotic) ? ' · EXOTIC' : ''}` : 'SELECT WEAPON'}</span></button>{/each}</div>
<div class="loadout-slot-group gear">{#each gearSlots as slot}{@const item = gearItem(slot)}<button class:active={active === `gear:${slot}`} onclick={() => active = `gear:${slot}`}><small>{slot}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? brand(draft.gear[slot].brandId)?.name ?? 'CHOOSE BRAND' : 'SELECT GEAR'}</span></button>{/each}</div>
<div class="loadout-slot-group systems">{#each [0,1,2] as index}{@const item = chipItem(index)}<button class:active={active === `chip:${index}`} onclick={() => active = `chip:${index}`}><small>SKILL CHIP 0{index + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[index].quality : 'SELECT CHIP'}</span></button>{/each}<button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
<div class="loadout-slot-group systems"><button class:active={activeParts[0] === 'specialization' || activeParts[0] === 'chip'} onclick={() => active = 'specialization:focus'}><small>SPECIALIZATION FOCUS</small><strong>{selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'NOT CONFIGURED'}</strong><span>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS · ${draft.chips.filter((chip) => chip.setId).length} / 9 CHIPS` : 'SELECT FOCUS'}</span></button><button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
</section>
<section class="loadout-editor">
{#if activeParts[0] === 'gear'}
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
<CombatAssumptions bind:profile={draft.specialization.combatProfile}/>
{#if selectedCombatEffects.length}
<div class="builder-conditional-effects"><div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>{#each selectedCombatEffects as effect}<ConditionalEffectCard id={effect.id} name={effect.definition?.name ?? effect.nodeName} description={combatEffectDescription(effect)} facts={[`${effect.trigger_count} HITS WITHIN ${effect.trigger_window_seconds}s`, `+${effect.stacks_per_trigger} STACK · MAX ${effect.max_stacks}`, `EXPIRES ${effect.duration_seconds}s AFTER LAST GAIN`, ...effect.modifiers.map((modifier: any) => `${unitValue(modifier.value_per_stack,modifier.stat_attribute_id)} ${attribute(modifier.stat_attribute_id)?.name ?? 'ATTRIBUTE'} PER STACK`)]} appliedState={draft.specialization.effectStates[effect.id]?.mode === 'peak' ? `APPLIED: ${effect.max_stacks}/${effect.max_stacks}` : draft.specialization.effectStates[effect.id]?.mode === 'encounter' ? `APPLIED: 0→${effect.max_stacks}/${effect.max_stacks}` : 'NOT APPLIED'} bind:effectState={draft.specialization.effectStates[effect.id]}/>{/each}</div>
{/if}
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
@@ -408,7 +440,7 @@
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} showIndicator={false} required/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
@@ -429,9 +461,10 @@
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}
<div class="loadout-editor-head"><div><span>SKILL SYSTEM</span><h2>SKILL CHIP 0{index + 1}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<BuilderPicker items={data.chipSets} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="SKILL CHIP SET"/>
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← RETURN TO SPECIALIZATION</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
{:else}
{@const protocol = protocolItem()}
@@ -444,6 +477,7 @@
<aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
+16 -5
View File
@@ -1,6 +1,9 @@
import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
@@ -14,21 +17,29 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
WHERE builds.slug=?
`)).get(params.slug);
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
let loadout;
try { loadout = JSON.parse(String(build.loadout_json)); } catch { error(404, 'Build data is unavailable'); }
const [gear, weapons, chips, protocols, brands] = await Promise.all([
let loadout: LoadoutDraft;
try {
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, combatCatalog] = await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all()
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database)
]);
const combatResults = evaluateLoadoutCombat(loadout, combatCatalog, summarizeLoadout(loadout, combatCatalog));
return {
build: {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
},
loadout, gear, weapons, chips, protocols, brands
loadout, gear, weapons, chips, protocols, brands, combatResults
};
};
+14 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
@@ -14,6 +15,18 @@
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><p>Filed by <strong>{data.build.author}</strong> · Updated {new Date(data.build.updatedAt).toLocaleDateString()}</p></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
<div class="builder-section-title">
<div>
<span>LIVE COMBAT MODEL</span>
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
</div>
<b>CURRENT ISAC RULES</b>
</div>
{#each data.combatResults as result}
<WeaponCalculationResult {result}/>
{/each}
</section>
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
@@ -24,7 +37,7 @@
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>04 SLOTS</b></header>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
</section>
+17
View File
@@ -0,0 +1,17 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessSettings, SITE_SECTION_KEYS } from '$lib/server/site-access';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
const settings = await getSiteAccessSettings();
const unavailable = url.searchParams.get('unavailable');
const unavailableSection = SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
if (!user && !settings.comingSoonEnabled && !unavailableSection) redirect(303, '/');
return {
settings,
authenticated: Boolean(user),
unavailable: unavailableSection
};
};
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import BrandEmblem from '$lib/components/BrandEmblem.svelte';
import BrandLockup from '$lib/components/BrandLockup.svelte';
let { data } = $props();
type ChannelKey = 'news' | 'database' | 'builds' | 'guides' | 'builder';
const channels: Array<{ key: ChannelKey; label: string; detail: string }> = [
{ key: 'news', label: 'FIELD REPORTS', detail: 'Resurgence news and community intelligence' },
{ key: 'database', label: 'ISAC ARCHIVE', detail: 'Gear, weapons, talents, mods, and game data' },
{ key: 'builds', label: 'AGENT LOADOUTS', detail: 'Published builds from the SHD community' },
{ key: 'guides', label: 'FIELD MANUALS', detail: 'Detailed tactics, play styles, and build guides' },
{ key: 'builder', label: 'LOADOUT LAB', detail: 'A precise character and equipment builder' }
];
</script>
<svelte:head>
<title>SHD.build — Network deployment in progress</title>
<meta name="description" content="SHD.build is preparing a data and tools network for The Division Resurgence."/>
<meta name="robots" content="index,follow"/>
</svelte:head>
<main class="launch-page">
<div class="launch-grid" aria-hidden="true"></div>
<header class="launch-header">
<a class="brand" href="/coming-soon" aria-label="SHD.build coming soon">
<BrandLockup size="launch" />
</a>
<div class="launch-signal"><i></i><span>NETWORK STATUS</span><b>DEPLOYMENT IN PROGRESS</b></div>
</header>
<section class="launch-hero">
<div class="launch-copy">
<p class="eyebrow"><span></span> THE DIVISION RESURGENCE DATA & TOOLS</p>
<h1>INTELLIGENCE<br/><em>IS COMING.</em></h1>
<p class="launch-lede">SHD.build is assembling a tactical home for Resurgence agents—loadout engineering, field guides, game data, community builds, and mission-ready intelligence in one network.</p>
{#if data.unavailable}
<p class="launch-notice" role="status"><b>{data.unavailable.toUpperCase()} CHANNEL OFFLINE</b><span>This section is offline for all agents while deployment continues. Use an available channel or return later.</span></p>
{/if}
<div class="launch-actions">
<a class="primary" href={data.authenticated ? '/' : '/sign-in'}>{data.authenticated ? 'ENTER FULL SITE' : 'AGENT SIGN IN'} <span>→</span></a>
<a class="secondary" href="https://discord.gg/dhpd33VwQK">JOIN THE DISCORD ↗</a>
</div>
<p class="launch-access-note">{data.settings.accountCreationEnabled ? 'NEW AGENT REGISTRATION IS CURRENTLY OPEN.' : 'SIGN-IN IS LIMITED TO EXISTING AGENT IDENTITIES.'}</p>
</div>
<div class="launch-core" aria-label="SHD network deployment status">
<div class="launch-radar">
<div class="radar-ring ring-a"></div>
<div class="radar-ring ring-b"></div>
<div class="radar-ring ring-c"></div>
<div class="radar-sweep"></div>
<div class="radar-core"><BrandEmblem size="76%" /></div>
{#each channels.slice(0, 4) as channel, index}
<div class="radar-node node-{index}"><i></i><span>{channel.label}</span></div>
{/each}
</div>
<div class="launch-core-status"><span>ISAC NODE // NYC-01</span><b>COMMUNITY UPLINK INITIALIZING</b><small>SECURE ACCESS AVAILABLE</small></div>
</div>
</section>
<section class="launch-channels" aria-labelledby="launch-channels-title">
<header><div><p class="eyebrow"><span></span> DEPLOYMENT MANIFEST</p><h2 id="launch-channels-title">WHAT’S ENTERING THE NETWORK</h2></div><b>{channels.length.toString().padStart(2, '0')} CHANNELS</b></header>
<div>
{#each channels as channel, index}
<article>
<small>{String(index + 1).padStart(2, '0')} // {channel.label}</small>
<h3>{channel.detail}</h3>
<span class:available={data.settings.sections[channel.key]}> {data.settings.sections[channel.key] ? 'STAGED' : 'IN DEVELOPMENT'} </span>
</article>
{/each}
</div>
</section>
<footer class="launch-footer">
<p>UNOFFICIAL FAN PROJECT FOR TOM CLANCY’S THE DIVISION RESURGENCE</p>
<nav aria-label="Prelaunch support and legal"><a href="/contact">CONTACT</a><a href="/terms">TERMS & PRIVACY</a><a href="/dmca">DMCA</a></nav>
</footer>
</main>
+88 -2
View File
@@ -1,4 +1,5 @@
import { getDatabase } from '$lib/server/database';
import { getRegistrySearch } from '$lib/server/pagination';
import { databaseCategories } from '$lib/database/categories';
import type { PageServerLoad } from './$types';
@@ -13,14 +14,99 @@ const tables = {
'skill-chips': 'skill_chip_sets'
} as const;
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ url }) => {
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const counts = await Promise.all(databaseCategories.map(async (category) => {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
return { ...category, count: Number(row?.count ?? 0) };
}));
const searchResults = search
? await (await database.prepare(`
SELECT * FROM (
SELECT 'gear' AS slug,'Gear' AS category,item.name,
item.slot || ' · T' || item.tier AS detail
FROM gear item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'weapons','Weapons',item.name,
item.category || ' · ' || item.damage_type || ' damage'
FROM weapons item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type
FROM weapon_mods item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'gear-mods','Gear Mods',item.name,'Gear modification'
FROM gear_mods item
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
UNION ALL
SELECT 'os-protocols','OS Protocols',item.name,
item.quality || ' · ' || item.primary_attribute_type
FROM os_protocols item
LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'talents','Talents',item.name,item.category
FROM talents item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talent_tiers tier
WHERE tier.talent_id=item.id AND (
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'brands','Brands',item.name,
COALESCE((
SELECT GROUP_CONCAT(attribute.name, ', ')
FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
),'Brand Set bonuses')
FROM brand_sets item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set')
FROM skill_chip_sets item
LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
) matches
ORDER BY name COLLATE NOCASE
LIMIT 40
`)).all(...Array(23).fill(pattern))
: [];
return {
registries: counts,
total: counts.reduce((total, registry) => total + registry.count, 0)
total: counts.reduce((total, registry) => total + registry.count, 0),
search,
searchResults: searchResults.map((result) => ({
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? '')
}))
};
};
+37 -10
View File
@@ -1,28 +1,55 @@
<script lang="ts">
let { data } = $props();
const onlineRegistries = $derived(data.registries.filter((registry) => registry.count > 0).length);
</script>
<svelte:head>
<title>Game database — SHD.build</title>
<title>The Division Resurgence database — SHD.build</title>
<meta name="description" content="Community-maintained The Division Resurgence attributes, talents, brands, gear, weapons, mods, protocols, and Skill Chip data."/>
</svelte:head>
<main class="page community-index-page">
<header class="catalogue-hero community-index-hero">
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE</p><h1>GAME<br/><em>DATABASE.</em></h1><p>Community-maintained Resurgence data powering SHD.build tools, loadouts, and reference material.</p></div>
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE</p><h1>RESURGENCE <em>DATABASE.</em></h1><p>Explore equipment, talents, modifications, and gameplay effects from The Division Resurgence.</p></div>
<div class="catalogue-count"><strong>{onlineRegistries} REGISTRIES ONLINE</strong><span>{data.total} ACTIVE RECORDS</span><a class="secondary" href="/isac">CONTRIBUTE THROUGH ISAC →</a></div>
</header>
<section class="community-database-intro">
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Select a registry to inspect active, approved records and their resolved gameplay attributes.</small></div>
<div><span>DATA STEWARDSHIP</span><strong>COMMUNITY + OVERSIGHT</strong><small>Registered agents can inspect source data and propose verified corrections through ISAC.</small></div>
<section class="database-global-search" aria-labelledby="database-search-heading">
<div>
<span>DATABASE INDEX</span>
<strong id="database-search-heading">SELECT A REGISTRY OR SEARCH ALL DATA</strong>
</div>
<form method="GET" action="/database" role="search">
<label class="sr-only" for="database-global-query">Search all database categories</label>
<input id="database-global-query" name="q" type="search" value={data.search} placeholder="SEARCH ITEMS, TALENTS, ATTRIBUTES, OR EFFECTS" maxlength="80"/>
<button type="submit">SEARCH ALL DATA</button>
{#if data.search}<a href="/database" aria-label="Clear database search">CLEAR</a>{/if}
</form>
</section>
{#if data.search}
<section class="database-global-results" aria-live="polite">
<header><div><span>SEARCH RESULTS</span><strong>“{data.search}”</strong></div><b>{data.searchResults.length}{data.searchResults.length === 40 ? '+' : ''} MATCHES</b></header>
{#if data.searchResults.length}
<div>
{#each data.searchResults as result}
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
<small>{result.category}</small>
<strong>{result.name}</strong>
<span>{result.detail}</span>
<b>VIEW →</b>
</a>
{/each}
</div>
{:else}
<p>No database entries match this search. Try an item name, category, attribute, talent effect, or damage type.</p>
{/if}
</section>
{/if}
<section class="database-registry-grid">
{#each data.registries as registry, index}
{#each data.registries as registry}
<a href={`/database/${registry.slug}`}>
<header><small>{String(index + 1).padStart(2, '0')} // {registry.code}</small><b>{registry.count}</b></header>
<h2>{registry.label}</h2>
<header><h2>{registry.label}</h2><b>{registry.count}</b></header>
<p>{registry.description}</p>
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
<footer><small>{registry.count ? `${registry.count} ENTRIES` : 'NO ENTRIES YET'}</small><span>OPEN REGISTRY →</span></footer>
</a>
{/each}
</section>
@@ -266,7 +266,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
case 'skill-chips':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
icons.updated_at AS icon_updated_at
@@ -274,6 +274,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
@@ -527,6 +529,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
facts: [
{ label: 'SPECIALIZATION ABILITY', value: row.ability_name ? `${row.ability_name} · ${row.specialization_name}` : 'UNASSIGNED' },
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
+5 -7
View File
@@ -1,31 +1,29 @@
<script lang="ts">
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
import DatabaseFacetFilters from '$lib/components/DatabaseFacetFilters.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import DatabaseDiscoveryConsole from '$lib/components/DatabaseDiscoveryConsole.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.category.label} database — SHD.build</title>
<title>{data.category.label} — The Division Resurgence database — SHD.build</title>
<meta name="description" content={`${data.category.description} Community-maintained data for The Division Resurgence.`}/>
</svelte:head>
<main class="page database-category-page">
<header class="database-category-command">
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / {data.category.code}</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / DATABASE REGISTRY</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
<div><strong>{data.pagination.total}</strong><span>ACTIVE RECORDS</span><a href="/database">DATABASE INDEX →</a></div>
</header>
<DatabaseCategoryNav active={data.category.slug}/>
{#if data.filters?.length}<DatabaseFacetFilters groups={data.filters}/>{/if}
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<section class="database-item-grid">
{#each data.items as item}
<DatabaseItemCard item={item as any}/>
{:else}
<div class="database-empty">
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `This registry is awaiting approved ${data.category.label.toLocaleLowerCase()} data.`}</p>
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `No ${data.category.label.toLocaleLowerCase()} have been added yet.`}</p>
</div>
{/each}
</section>
+8 -8
View File
@@ -1,21 +1,21 @@
<svelte:head>
<title>DMCA & Copyright Policy — SHD.build</title>
<title>DMCA & Copyright Policy | SHD.build</title>
<meta name="description" content="SHD.build copyright, DMCA notice, counter-notice, and repeat-infringer policy."/>
</svelte:head>
<main class="legal-page">
<header class="legal-hero"><p class="eyebrow"><span></span> SHD COPYRIGHT OPERATIONS</p><h1>DMCA & <em>COPYRIGHT.</em></h1><p>SHD.build respects intellectual-property rights and provides a documented channel for copyright owners, authorized agents, and affected users.</p></header>
<header class="legal-hero"><p class="eyebrow"><span></span> SHD COPYRIGHT OPERATIONS</p><h1>DMCA & <em>COPYRIGHT.</em></h1><p>SHD.build respects intellectual-property rights. Copyright owners, their authorized agents, and affected users can use this policy to submit claims and counter-notices.</p></header>
<div class="legal-layout">
<nav class="legal-index" aria-label="DMCA policy sections">
<a href="#notice">01 · TAKEDOWN NOTICE</a><a href="#processing">02 · PROCESSING</a><a href="#counter">03 · COUNTER-NOTICE</a><a href="#repeat">04 · REPEAT INFRINGERS</a><a href="#misrepresentation">05 · ACCURACY</a><a href="#contact">06 · SUBMIT</a>
</nav>
<article class="legal-document">
<section id="notice"><span>01</span><div><h2>NOTICE OF CLAIMED INFRINGEMENT</h2><p>If you own a copyright or are authorized to act for its owner and believe material on SHD.build infringes that copyright, submit a written notice containing: your physical or electronic signature; identification of the copyrighted work or a representative list; identification and exact location of the material to remove or disable; sufficient contact information, including mailing address, telephone number, and email address; a good-faith statement that the disputed use is not authorized by the owner, its agent, or law; and a statement under penalty of perjury that the notice is accurate and you are authorized to act for the owner.</p><p>Use exact SHD.build URLs whenever possible. A notice that omits required information may delay review while we request clarification.</p></div></section>
<section id="processing"><span>02</span><div><h2>REVIEW & REMOVAL</h2><p>We review sufficiently complete notices and may remove or disable access to identified material expeditiously where appropriate. We may provide the notice, including the claimant’s identity and allegations, to the user who supplied the material so that user can understand the claim and exercise applicable counter-notice rights.</p><p>Submitting a notice does not guarantee removal, constitute a legal ruling, or waive any party’s rights.</p></div></section>
<section id="counter"><span>03</span><div><h2>COUNTER-NOTIFICATION</h2><p>A user who believes material was removed through mistake or misidentification may submit a counter-notice identifying the removed material and its prior location; providing a physical or electronic signature and complete contact information; stating under penalty of perjury a good-faith belief that removal resulted from mistake or misidentification; consenting to the jurisdiction of the appropriate United States federal district court; and agreeing to accept service of process from the original claimant or that claimant’s agent.</p><p>Where applicable, SHD.build may restore material no sooner than 10 and no later than 14 business days after forwarding a compliant counter-notice, unless the original claimant informs us that a court action has been filed seeking to restrain the allegedly infringing activity.</p></div></section>
<section id="repeat"><span>04</span><div><h2>REPEAT-INFRINGER POLICY</h2><p>SHD.build may restrict or terminate accounts of repeat infringers in appropriate circumstances. We consider the nature, credibility, and outcome of notices and counter-notices rather than treating every allegation as a final determination. We may also remove content or restrict accounts when infringement is otherwise established or apparent.</p></div></section>
<section id="misrepresentation"><span>05</span><div><h2>ACCURACY & MISREPRESENTATION</h2><p>Knowingly making a material misrepresentation that content is infringing, or that removed content was removed through mistake or misidentification, may result in liability under 17 U.S.C. § 512(f). Consider fair use, licenses, and other lawful uses before submitting a notice. This policy is operational information and not legal advice.</p><p>For official guidance, consult the <a href="https://www.copyright.gov/512/" rel="external noreferrer">U.S. Copyright Office Section 512 resources</a>.</p></div></section>
<section id="contact"><span>06</span><div><h2>SUBMIT A REQUEST</h2><p>Use the DMCA / Copyright destination on the SHD.build contact form. The form collects the elements needed for an initial takedown notice and routes the complete record to administrators. Counter-notices and follow-up documents may use the same channel.</p><a class="primary legal-primary" href="/contact?team=dmca">OPEN COPYRIGHT REQUEST →</a></div></section>
<section id="notice"><span>01</span><div><h2>NOTICE OF CLAIMED INFRINGEMENT</h2><p>If you own a copyright, or the owner has authorized you to act, and you believe material on SHD.build infringes that copyright, submit a written notice containing: your physical or electronic signature; identification of the copyrighted work or a representative list; identification and exact location of the material to remove or disable; sufficient contact information, including mailing address, telephone number, and email address; a good-faith statement that the owner, the owner’s agent, and the law do not authorize the disputed use; and a statement under penalty of perjury that the notice is accurate and you are authorized to act for the owner.</p><p>Use exact SHD.build URLs when possible. Missing information may delay our review while we request clarification.</p></div></section>
<section id="processing"><span>02</span><div><h2>REVIEW & REMOVAL</h2><p>We review notices that contain enough information and may remove the identified material or disable access expeditiously when appropriate. We may provide the notice, including the claimant’s identity and allegations, to the user who supplied the material. The user can then understand the claim and exercise applicable counter-notice rights.</p><p>Submitting a notice does not guarantee removal, constitute a legal ruling, or waive any party’s rights.</p></div></section>
<section id="counter"><span>03</span><div><h2>COUNTER-NOTIFICATION</h2><p>If you believe we removed material because of mistake or misidentification, you may submit a counter-notice that identifies the removed material and its prior location; provides your physical or electronic signature and complete contact information; states under penalty of perjury your good-faith belief that mistake or misidentification caused the removal; consents to the jurisdiction of the appropriate United States federal district court; and agrees to accept service of process from the original claimant or that claimant’s agent.</p><p>Where applicable, SHD.build may restore material no sooner than 10 and no later than 14 business days after we forward a compliant counter-notice, unless the original claimant informs us that it filed a court action seeking to restrain the allegedly infringing activity.</p></div></section>
<section id="repeat"><span>04</span><div><h2>REPEAT-INFRINGER POLICY</h2><p>SHD.build may restrict or terminate accounts of repeat infringers in appropriate circumstances. We assess the nature, credibility, and outcome of notices and counter-notices without treating each allegation as a final determination. We may remove content or restrict accounts when other evidence establishes or indicates infringement.</p></div></section>
<section id="misrepresentation"><span>05</span><div><h2>ACCURACY & MISREPRESENTATION</h2><p>If you knowingly make a material misrepresentation that content infringes a copyright, or that mistake or misidentification caused us to remove content, you may incur liability under 17 U.S.C. § 512(f). Review fair use, licenses, and other lawful uses before submitting a notice. This policy explains our process and does not provide legal advice.</p><p>Consult the <a href="https://www.copyright.gov/512/" rel="external noreferrer">U.S. Copyright Office Section 512 resources</a> for official guidance.</p></div></section>
<section id="contact"><span>06</span><div><h2>SUBMIT A REQUEST</h2><p>Select DMCA / Copyright on the SHD.build contact form. We use the form to collect the information required for an initial takedown notice and send the complete record to administrators. You may send counter-notices and follow-up documents through the same channel.</p><a class="primary legal-primary" href="/contact?team=dmca">OPEN COPYRIGHT REQUEST →</a></div></section>
</article>
</div>
</main>
+16
View File
@@ -0,0 +1,16 @@
import { getDatabase } from '$lib/server/database';
import { readManagedImage } from '$lib/server/managed-images';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const image = await readManagedImage(await getDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
};
+4
View File
@@ -4,6 +4,7 @@ import { redirect } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import type { LayoutServerLoad } from './$types';
import { getContributorMode } from '$lib/server/contributor-mode';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -23,9 +24,12 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const pendingAvatarReviews = canReviewAvatars
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
return {
permissions,
contributorMode,
buildNumber,
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0,
+18 -6
View File
@@ -7,12 +7,14 @@
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
const guidelinesPage = $derived(page.url.pathname === '/isac/contributor-guidelines');
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
const isacNode = 'NYC-01';
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
{ href: '/isac/brands', label: 'Brand sets', module: 'brands' },
@@ -24,6 +26,8 @@
{ href: '/isac/skillchips', label: 'Skill Chips', module: 'skillchips' }
];
const dataModulePage = $derived(dataSections.some((section) => page.url.pathname === section.href));
const dataContextPage = $derived(dataModulePage || guidelinesPage);
const dataReadOnly = $derived(dataModulePage && can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled));
const userSections = [
{ href: '/isac/users', label: 'User operations', permission: 'users.view' },
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
@@ -36,6 +40,8 @@
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
];
@@ -75,7 +81,13 @@
<main class="admin-page">
<header class="admin-command">
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
<div class="command-status compact-status" aria-label="ISAC system status"><span><i></i>SYSTEM <strong>ONLINE</strong></span><span>ACCESS <strong>AUTHORIZED</strong></span><span>ENV <strong>LOCAL</strong></span><span class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}>MODE <strong>{dataMode}</strong></span></div>
<div class="command-status compact-status" aria-label="ISAC system status">
<span><i></i><small>SYSTEM</small><strong>ONLINE</strong></span>
<span><small>ACCESS</small><strong>AUTHORIZED</strong></span>
<span><small>NODE</small><strong>{isacNode}</strong></span>
<span><small>BUILD</small><strong>{data.buildNumber}</strong></span>
{#if dataContextPage}<span class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><small>MODE</small><strong>{dataMode}</strong></span>{/if}
</div>
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
@@ -106,7 +118,7 @@
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><span>{section.label}</span><b>›</b></a>
{/each}</nav>{/if}
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
<div class="sidebar-foot"><span>ISAC NODE</span><strong>{isacNode}</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
@@ -114,10 +126,10 @@
readOnlyBlocked = true;
}
}}>
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataModulePage && page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{:else if dataModulePage && contributorEnabled && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if dataModulePage && readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
{@render children()}
</section>
+2
View File
@@ -9,6 +9,8 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
+35 -10
View File
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation';
@@ -15,7 +15,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
}])).map((stat) => ({
...stat,
calculation_type: stat.calculationType,
@@ -33,6 +33,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC' },
type: { asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
category: { asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
calculation: { asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
}, 'name');
const conditions = [
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
@@ -42,21 +49,26 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const visiblePending = pending.filter((stat) =>
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
&& (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
);
).sort((left, right) => {
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
const result = String((left as any)[field] ?? '').localeCompare(String((right as any)[field] ?? ''), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args);
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const stats = databaseLimit
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
: [];
return {
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
pagination,
filter,
search
search,
sort
};
};
export const actions: Actions = {
@@ -64,20 +76,33 @@ export const actions: Actions = {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; };
const number = (key: string, allowNegative = true) => {
const raw = String(form.get(key) ?? '').trim();
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || (!allowNegative && value < 0)) throw new Error();
return value;
};
let values: Array<number|null>;
try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); }
try {
values = [
...['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax'].map((key) => number(key)),
number('capValue', false)
];
} catch { return fail(400, { error: 'Ranges must be numbers. Optional caps cannot be negative.' }); }
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?')).get(name, unit, id || 'new')) return fail(409, { error: 'That attribute and unit combination already exists.' });
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
if (id) 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=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
const savedItem = await auditRecord('stat_attributes', statId);
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
+13 -3
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
@@ -12,8 +14,11 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
const attributeMeta = (stat: any) => stat.pending
? `PROPOSED BY ${stat.proposerName}`
: [stat.description, stat.unit || 'NO UNIT'].filter(Boolean).join(' · ');
$effect(() => {
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -25,9 +30,14 @@
}
});
</script>
{#snippet attributeEditor()}
{#if editing}
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
{/if}
{/snippet}
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
{#if editing && !editing.id}{@render attributeEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each visibleStats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small>{stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
<div class="dashboard-table"><div class="dashboard-row table-heading"><SortableColumnHeader label="ATTRIBUTE" column="name" sort={data.sort}/><SortableColumnHeader label="CLASSIFICATION" column="type" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="CALCULATION" column="calculation" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each visibleStats as stat}<div class="dashboard-row attribute-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending} class:registry-row-editing={editing?.id === stat.id}><div><strong>{stat.name}</strong><small title={attributeMeta(stat)}>{attributeMeta(stat)}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button aria-expanded={editing?.id === stat.id} aria-controls={`attribute-editor-${stat.id}`} onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{#if editing?.id === stat.id}<RegistryInlineEditor id={`attribute-editor-${stat.id}`} label={`Edit ${stat.name}`}>{@render attributeEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+23 -6
View File
@@ -1,7 +1,7 @@
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
@@ -47,6 +47,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
const category = url.searchParams.get('category')?.trim() ?? '';
const action = url.searchParams.get('action')?.trim() ?? '';
const sort = getRegistrySort(url, {
event: {
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC'
},
operator: {
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC'
},
target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC' },
time: {
asc: 'audit_log.created_at ASC',
desc: 'audit_log.created_at DESC',
defaultDirection: 'desc'
}
}, 'time', 'desc');
const conditions: string[] = [];
const args: unknown[] = [];
if (query) {
@@ -73,7 +89,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration
FROM audit_log`)).get()
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
@@ -81,13 +98,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login
FROM audit_log JOIN users ON users.id=audit_log.actor_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
${where} ORDER BY audit_log.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : [];
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
return {
entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })),
categories, actions, summary, pagination, filters: { query, category, action }, canRevert
categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort
};
};
@@ -113,9 +130,9 @@ export const actions: Actions = {
if (action.startsWith('attribute.')) {
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
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=?,updated_at=? WHERE id=?`))
.run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
.run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
} else if (action.endsWith('.archived')) {
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
+3 -1
View File
@@ -3,6 +3,7 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
let { data, form } = $props();
let confirming = $state<any>(null);
let handledResultToken = $state('');
@@ -32,6 +33,7 @@
<KpiFilters className="audit-kpis" parameter="category" active={data.filters.category} items={[
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
@@ -45,7 +47,7 @@
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row audit-row table-heading"><span>EVENT</span><span>OPERATOR</span><span>TARGET</span><span>TIME</span><span></span></div>
<div class="dashboard-row audit-row table-heading"><SortableColumnHeader label="EVENT" column="event" sort={data.sort}/><SortableColumnHeader label="OPERATOR" column="operator" sort={data.sort}/><SortableColumnHeader label="TARGET" column="target" sort={data.sort}/><SortableColumnHeader label="TIME" column="time" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.entries as entry}
<details class="audit-entry">
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>

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