Compare commits
10
Commits
2e868d86ff
...
5a67b252aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a67b252aa | ||
|
|
7b3aff30d8 | ||
|
|
8b80e55e6c | ||
|
|
6ea45c05fa | ||
|
|
338ea8761d | ||
|
|
b1656c430f | ||
|
|
6722665f5c | ||
|
|
ed31748102 | ||
|
|
63b04d26a9 | ||
|
|
e8ed35e35f |
+13
-12
@@ -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==
|
||||
|
||||
@@ -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
@@ -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
@@ -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==
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -36,3 +36,5 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
/_img
|
||||
+87
-1
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+360
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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('"', '""')}"`;
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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.`);
|
||||
@@ -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}`);
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+23
-1
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg';
|
||||
+18
-1
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 ?? '')
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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` }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user