This commit is contained in:
Nick Beckley 2026-06-01 14:32:42 +01:00
parent 624ec9a394
commit 4c8f97d048
48 changed files with 1059 additions and 85 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPokJR1AcF7MFwWqyRpjESd3exN4ocxkRk0qAop0GR/76gJNdO0cPehBFn5r03b+YHsuIAqEWr4/HTCWwYUIqCmUKU0AqTCpN8l9O2pC3hGWclY1X5L0qRuYM31KKaSZlLDriACjBTpMRdoSVWCnhOckVoyWJN8Tyg9gcSJlNwMhIyN/wFfjDOohb9baxyr7G3MJns3agy73rY2CEIKyOYr5v1Hf6y2u4etvOrAYaYnBmFWpQzUpayo1gB0ZhVHcNcCYIFr/JOS9Yw4HOasQG8FX02qJdLf+WChSBbmtEiI5hy9J4iJYKIkDsxwO7tGFFMEt3GeDqFp8/2BzqBRy1Nz2LO5W5kb24OQzeaXt0E4Z8haC8O8Or8C5xEj1J09O4IPhiYtHl4Si6QcpKRjFaUEJSieyH3xkKyuU0e+khYyIYwUAXmoBVmPBeYs6LAqihFTStd11SjFN2Bt9BfVb030rvB6ZAskicf+qydDcaObhySjQ3w7EWY8z6LfgCUou3Y9WbYP4JQbzv3OMZ/duwjl/XoPdiQbEXYJ3+KYY9aVFY1LRlhUpRFDlRqTokmpOEFVYyCrCsFNaWyY4zrQhel7ITs6kZ3oIquzlGKHsZwHMNZ/+9XIHbeHA6gLgh2k8bbXgTt/GGjwAajTRzN+cVkqL6fWCVLzXVvwIYPIqMWkRyoKhrAmuYVZo0SWJQFx1VVVBpyyWsa0a2d1eZ5/KkT0VqxQpdNU3W8ywlQXTeM1LojSupCclV2UNOa1A00XBLSlSxvOi6JgrxkKrLzTo0y/GqFYhrQ42jjdiabSPvVWPy9YhdFtt4FJ10fB2Z6eHobAhx+N7W1OxzdYELkr5fRfhvg3tn4d9n/RbMleeXCJQZxD5Jp9SqiteRC4kqJGrOKl7ihBceS5VCIuma1bFCKbp18AZ9Egwljp5mcPfXwlKy83JsAMow+dv5nIjWP8/N5ZS56ZF8/7dbOw+p4TJfoH6co0lzsbIAPd+XbEJ/v3RBZBD9e+egJen3GdeUmBaf5UpmJplINqCrHlOUlZpznuOFljbu66XhXNVDKeGFmtyaPoIwHGaZ6u7nbIuzvbLnywWghwxB354uTk5sWey5PfgV5NbWf2XPzq8ktTloq5SQ69mm+m5OB/tfZfH9/f/8PAAD//wMAbXToGicGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUXW/bNhSG/4rAa1EhKYoSdZc5K2akbdzGa4fdUeSRQ0QhDZJKFwT+7wNlB7Fb7GJ3AnXe8/Geh3xFTj0B6tH1fv/7M7gUUYmSXY4YYQITgQndUtY3rCeyajlnom3/RiWyt/CCetRyramWgGsQgLkhDR7qUWNjQFJCDWENzznVLqL+FSlbGXi2Giofv0GI1jvUo+/WGf8j11a20pOfTRX8BGsXk3I6N/N5vbrFH68327vNMSpCzOLKGtQjyknT6IFhA2TEXAwcS6MIVlLQYdS84yCPMusSBKemKprH9/rGJwdJ96xidUUwk+hQIqOSyi0PKsL2ZZ+7WCy6yeflcrx89q/oGQLqWflm5jHdlZ7s1VMcZjuZq8nvdtbtVt6NdjcHlXLhEu2D30NIFhZzfrNOhZePfrfL+dAHNUVAJdpCeLJOTccfNzCqeUr3fg6LM9E8/lcM6pGak0clWpycpqVscRpmNVlw6RftWeG7+2IzqTT68HSxpPuj9+v/Yf0npR+sg2J9g3pEKDBTd4BHRgXmnVFYNbXEQtRiBKplyygq0cq7ZN3s51isXYLdybWL/q6DfrAJdJpDnukvkWHbBG9mnYr3BVNSkaomJIvmtJ9T8RWMDaAT5CG2YT7l+0nCBFtEH+wEv9izmkMAl4qNSg/FHyo+oB41omUNJ1yrpqbA9CgZGQnpZM0MZ6BbYaBlTA+cy7Ee60YPSg9tNw5g6qHNY0OGrFi4ZgM0wJjEhnLAHDTBnewMVhIYNYyQrjMX5hZ3U9Yp3REOpsYSRoO5pApLXtfY1I1qmRjblo1nPm2PQFCSy59D+GcEc2Lp8xHtt9kNPB/v8dKnMKYDIyhmnDaYS0lxJ5sWD203yEF00OgMwcq76N+M/AZh8NGm/Ih8mS1kEi8C3vdyC8HBdLabT1YHH/2YihOUxcW2vs4uv2HFAugP6/A/CxY3Xj9CKDJWyrqzVR4Oh8O/AAAA//8DAH+Nh0gKBQAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUXW/cKBT9KxbPxuHL2PgtmrTaUbabUTvqrvYNw6VBcWAEOG0UzX9f4ZlVm6fVviCEOfeee87BbyjoZ0ATuj2dPrxAKBm1qPjtiBEmMZGY0CNlU88mSjpJlGJC/o1a5O/hFU1oEMZQowBzkICFJT2euTPYWlCUUEtYL2pN/S2j6Q1p31l48Qa6mL9Cyj4GNKE/fbDxe+2tfWeWuNouxQX2IRcdTCXzx353j3+/PRwfDpdbGXIFd96iCVFB+t7MDFsgDgs5C6ysJlgrSWdnxChAXWA+FEhBL122Tz/721gCFDOxjvGOYKbQuUVWF10pzzrD8fVUWWwS3dXzdjvettMbeoGEJtb+K+al3I1Z/M1znle/2JvLWnR+ytvqtCkxvaIWnVI8QSoeNn1uc4bneXk96vz08XKnbvOHH2DWAnYX11DqxHxALdoEWhZdfAzNleNu8RDK9rEkH7I3/1WLoxY9fGkOiy4upud3fny5yLz/Hyp/0ubRB2j2d2hChAKzfATsGJVYjFZj3XOFpeTSATVqYBS1aBdD8WGNa272ocC3tI2EJvRRLxku/G6TefQFTFlTnfMvWXN1SNGupjQ/vaSkIx0npILWclpL8xmsT2AK1CGOab3W+/oewiTbQLs1JQilOejy2Pym8yOaUC8H1gsijO45BWacYsQRMirOrGBgBmlhYMzMQijHHe/NrM08jG4Gy+ehTgg1Os2WVuEoFc4qPA6DwcIYgzWAxkSMPYAgehTzOx2bh6XitBmJAMuxAmexUFRjJTjHlvd6YNINA3O/SHLNAyW1vYWXy7vbGEhrR7CSYiZoj4VSFI+qH/A8jLOa5Qi9qU7eQwqw/CLUJ29SzNGV5pqQ5p10n9dQ/x3NlpbvPuAfm0d30TxBaqrH2of6VK62ns/n8z8AAAD//wMAC2rMKYIEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227cNhD9FYGvFRXeL3pzbQddpI6N7KIt+sbL0FYtUwuKchIY/vdCWjtXFOiLIAw5cw7PnJknlN0DoB6dHY+Xj5DrjFpUhy3ECFOYKEzogbJesp6STlEtuJF/oxYN7+Az6pEWIdBgAXNQgEUkEnueAo4RLCU0EibFWtPdzqh/Qm7oIjwOAbpp/gPKPEwZ9ejPIcfp44rthi6M0xK7Mo2wy3N1Oaxk3u/O3+Hfz24O1zenWzPMa3I3RNQjKoiUwTMcgSQslBfYRkews4r6FIQRYE9pQ65Qshu7Od5/xY9TzVBDzzrGO4KZRc8tiq66lbJ3Mxw+H1cWm0QXa7zdwttv/4QeoaCeta9insq9CePw5mH2yzDGN9sXtehYpiOUOsCmxvW++UqCko50TDFCUIuuXLgbMjS7i+Z6XJ/ogiECIscWUsTCUoet4BxHLp1mKmnNEmrROygZxm+qXg2hTPOUavMicvMdzv7s/DI7P8KK8daNM6AW7eZ9dTm6ccpw+QnCUk+1DmVZj8+nXIe8TMvc7HKF2+Jezl/zz5dSINfmxtW75jc336EeSaWZFEQEJzkFFpJlJBFiLGdRMAhaRdCMBS+ETTxxGbwLXpvkIXKvKWrRr6uG+yUEmOevbDaTjOPGoXnp0/k4QK7fyYh6RCiwyA3gxKjCwkSHneQWK8VVAhqsZl9gDq7cQkU9SsZ7T0LwTFgaHLOOx0R4VMkyprUTiUstmFKSaBei1oyKyDUExxMnhqEW3ZQpLqH+2Gq+NeCmTP9AqKtUqEeKSGmC854pQY2zBgRVwigL2orkiODBOyqN854SJ0mwhgVHlXXCR5nsK/3LfDtkuBjm4+g+f4NrOtXxX4JzhiYnfrj+trgH+DiV+/cnG3fvLw+oRR+WvG6EZrda5OOQ8Se1TvTVMtbhcFfARYhXU4SffQTruDSnCQ1KcM8DDjxRLEyS2EovsQYPkLxMTAXUoosp3ENpVoe5Ia9T9aXY9b45K+FuqBDqUlZ+f208rvfNzehqmsrDd5vkm4f9KEDHOCVrZyI8nrbRxlHFaCAqipmgEgtrKTZWauy18dYrAzKsil0v9bjU5gPEoUCo24tfvLhhnt9BuP9ZjFcXvFiUktVr+9MW26T9X0vsuUUP4OalwMO2rfsntMsZyoZ8sZxGcZevhnEcZghTjjPqjaGdoVq/EPyva5awTnOtn5+fn/8FAAD//wMAPZDLtBkGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS2/cIBD+KxbnxeFlML5Fm1ZdpW1WyaqtesMwdFEcvMI4D0X73yu8W6XprReERnwz32N4RdE8AOrQ5eHw4RFintAK5bCUGGESE4kJ3VHWNayjpJaUt4qSn2iFwjW8oA4pYS21GjAHCVg40uCee4udA00JdYQ1ovQ0vybUvSITagePwUI9Tt8gTWGMqEPfQ3TjU5ltQm2HcXZ1GgfYxCmbaAuZr5v1Nf58ud3dbE+vJpgKuA4OdYgK0jS2Z9gB8VjIXmDtDMFGS9p7K1oB+gQLMUOKZqgnd/823405QrYdqxmvCWYaHVfImWwK5d5MsHs5FBaLRVelvlrKy7V7RY+QUMdWf8w8tbuwQ7h4mPo5DO7idGYz3ReVhzQeIOUAiyeLzGEwOYyxOk9aDwFiRit0c1dtB5P9mB7eGXV30r/5D/m7Mv3DM9g5g1uPc8wnLFqhL8buQ4Rqc4U6RCgwx1vAnlGJResMNg3XWEouPVCrFaNohdZjzCHO4zxVm5jhV1oEoA59NMMEJ+qXye5DBpvnVFT9kGUXtml0s83Vm/+U1KTmpDC5mfNhztUtuJDAZij6dmk+9/sHwiRbQOs5JYi52pq8rz6ZaY861EjFGkGENQ2nwKzXjHhCWs2ZEwyskg4UY7YXQnvueWN7Y3vV+h4c71VRCCXu6rxhTgLXCje2sVgw6nAvPcctgAKtlPLg3vlY3QwFZ2xLBDiONXiHhaYGa8E5drwxikmvFPN/WXJOn5Iy3sHj6a8sDKRzLThJMRO0wUJrilvdKNyrtte9bKGxJeRrSBGGv4z6Emwap9Hn6rw81TvrbudY/nu1LNJTiPh5yehqtPeQqpKxCbGs9znW4/F4/A0AAP//AwATWzwkNgQAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rA81LhlyhRN8Nu0YWbepEYbdHLghoOvaxlaktRTgNj/3tA7RqOm1NvEjHvfDx8hy8k2ickPbk6Hn96xphnsiE5rEeCCU2Zpozfc9E3oue85lJpppq/yIaEW/xKetIqAA4GqUSNVDnW0EF6oM6h4Yw7JhpVctqHmfQvxIba4XMArKf5d0xzmCLpyR8huulLqW1DDeO0uDpNI27jnG2E0sxv2+tb+uvV7v5ud46acS7iOjjSE65Y08AgqEPmqdKDosZZRq3RfPCgOoXmLAsxY4p2rGf3+FbfTTlihl7UQtaMCkNOG+JstqXlwc54//VYulgR3ZTzzXq8fvYv5BkT6cXmFeY53QcYw4e0RLIhxzQdMeWAK4N1rHG0OUyxumS+HgPGfA79GyHv17lEB61xg0GlWnBosZEOmBOKSezcwD1XCiSaDphRvGHgWSes96LprO8GJBty97najTb7KT29A/35zG/7P/B9tHAIEavtDekJ4yic7JB6wTVVnbPUNtJQraX2yMG0gpMNuZ5iDnGZlrnaxowPaZ2a9ORnO86X/q4SHEJGyEsqKP7UxTCjXSIc9sc0+TDiPsx7h94uYyY98RftLk1ugVy9XSRnNaslYyXxko9Lrj6hCwkhYxn0Pi2Xmv+RCC1W0fWSEsZc7Ww+VL/Y+UB60uhWNIopsI3kKMAbwTxjnZHCKYHQaoetEDAoZbz0soHBwtB2fkAnh7ZQwOKbar1SJ7QTzgEdpBBUeW3pIJDTdjDgQDrpNX/Hurobi85CxxQ6SQ16R5XhlholJXWysa3Qvm2FLxY/Hvf5bKlXJxWjuu9oXRzHGf8Rc8J/FpzPsC6898tZ7vD5vLnrGNq5Dp3mVCjeUGUMp51pWjq03WAG3WEDxTK3mCKO39H+GCBN8+RzdbFi9Y7/pyWW16dabfklRPrvaoabCR4xVcVMNsSybBf/nDbkCe28JHxaH6/+hRwtPNqHMorHhBFwD9MS87qfs3t8/eNvq/ZDJDudTqdvAAAA//8DAGGM7QYbBQAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27bMBD8FYFnU+FLlKhb4LSokaYxmqAteqPIZUxEIQ2KygOB/72g7CJJb70RC87s7MzuKwr6AVCPzvf7T48Q8oRWKPulxAiTmEhM6C1lfcN6RmqiJGuY/I1WyF/CC+pRK4yhRgHmIAELSxo8cGewtaAooZawRhROfTeh/hVpX1t49AbqOP2ANPkYUI9++mDjU+mtfW3GONs6xRE2Yco6mCLm22Z9ib+eb2+vt8dfE0wFXHuLekQFaRozMGyBOCzkILCymmCtJB2cEZ0AdYT5kCEFPdaTvX/rb2MOkE3PasZrgplChxWyOusiedAT3L7si4rFootSXy3l5dm/okdIqGerv2Ye6c7M6M9MfHjQwZ45H/y0Qyu0T3EPKXtY7FgmHEedfQzVqcl69BAyWqHrm2o76uxievjg0c1x9M1/TH6lzc4HqDYXqEeEArO8A+wYlVh0VmPdcIWl5NIBNaplFK3QOobswxznqdqEDHdpUYl69FmPExz1nSez8xlMnlOR/kuWrOHZ53W0pYAL0TZFO5tcvflNSU1qTkghmfN+ztV3sD6ByVCGuk3zif8fCJNsAa3nlCDkaqvzrvqipx3qUSNb1ggijG44BWacYsQR0inOrGBgWmmhZcwMQijHHW/MoM3Qdm4Ay4e2CIUSb7VslLKdNco4bAgoLGBgWFE9YDVwrQ20ClT3wdfqeiw4bToiwHKswFksFNVYCc6x5Y1umXRty9w7S06RU1LaW3g83saiQFrbgZUUM0EbLJSiuFNNi4e2G9QgO2hMSfYSUoDxnVFX3qQ4RZer08ZUH6z7Pody39WyPU8+4Ocls4to7iFVJXPtQ1nnU8yHw+HwBwAA//8DAHsvl/wmBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzm6Juht2ihptmERtt0duIM4qFaKUFRW0aGP7vBbVbJO6tN2LAGb585p15ZTMciXXs9nT66UxzXlnN8riHlFCOC8eFfJaqs6rTbSO0ts6rv1jNxkf6xjrmTYwyBuKaHHGDwvJeD5EjUpBColDWlJrweWXdK4OxQTqPkZpl/Z3SOi4z69gf44zL1/I2jE2clg2btEz0MK8Z5ljE/PZw98h/vT08fzxcbq20luRmRNYxHaV2ZiAevO65Ca3kwUrJvXZKSuxtq8IlbZwzpRmmZsUv39/HJc+UY6capRvBVWBvNUPIUCT3sNLzt1NRsSO6L/F6D+/H7pWdKbFO1f/CvJS7idN4s279RGeaTpBWSjdxOR5hRlazU1pOlPJIO5YzpX5Zx1yIxuihbaXEFryxHlttlfaoBWErvMWAAZxxg4rWDti3wZvBoJIGHRnTe1azHdw0QR6Xubpqv5tGmjOr2cen6jBBHpZ0fIf+6UL04X8ALbLLbWG1856M1hqC65Ug9C46p/oIwg+9IhlJ2GB8kF5IHZQIxUitRxAhspp9gPgyzlQ93LOOCUkKdUt8UNJx0yJwsDpw57QbSMbglWQ1u1vmPM7bsq3Vw5zpc9r/yzr2M0wrXX56m+LLmCnmLRUIf7pixkNacIu5+m4AKRrRaCFK0pZPW64+EY6JYqaC4zlt13r/SVFO7Ul3W0o05+oA+aX6BdYX1rEAbZQQIAC4GAeyggaBApzwSA4doVHo0QSDzgQRnYFeGgQr+6FEWM2o+K3aLa4sWOvbyI0NkpvBAu/bVnFHIAZDxmqw7zhWH6eSB7EVhlDzQANyEyTwYLTmqC145Qbv1fADkqtZpCiAkc6XYd0VOMSW0EmujLTchCB5G6znvW/70LuWbCyaHynNNP0A6sMY07IuQ66uXqveofu0zWXhVLvvvo4z/3vv0f0Sv1CqSo9hnMt8Xdv6VrMjwbolOu77qntlhzJc1fO+tpRupFO2Zk8ZUt5O17B2jQhSvr29vf0DAAD//wMAY0OS2PQEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27jNhSGX0XgWlR4EyVqlzod1MhkYkzcadEdRR45RGTSoKhMg8DvPqDsIM4MuuhOOOJ/Lt/5yVfk9R5Qh64Ph9+fwacJlSi5JcQIk5hITOiWsq5mHW8rUrdEKf4PKpG7hRfUoUYYQ40CzEECFpbUuOeDwdaCooRawmqRc+rdhLpXpF1l4dkZqML0DeLkgkcd+st5G77n2tpVZgyzrWIYYe2npL3JzXxZr27x5+vN9n5zOjXBlMWVs6hD3FAuxQBYNbzHQrUUq5pS3HDJKLV93TJ1kjmfIHo9VpN9eq9vQ/KQTMcqxiuCmULHElmddG651xNsXw65iwXRTY6XS3j57F7RM0TUsfIN5indlRnd1X7qZzfaqzHsds7vVsEPbjdHnXLhEh1iOEBMDhY4vzmv48vnsNvlfOiTHidAJdpC3Duvx9OPGxj0PKaHMMeFzGSf/usM6pCeU0AlWkiO41K2OA+zGh349Iv2ovD9Q7EZdRpC3H9Y0sOJ/fp/oL/T5tF5KNY3qEOEArO8BTwwKrForca65gpLyeUA1KiGUVSiVfDJ+TnMU7H2CXZnah/6u47m0SUwaY55pr9lNtsmBjubVLwvmJKKVJyQLJrTYU7FV7AugkmQh9jG+ZzvJwmTbBF9ciP8gmc1xwg+FRudHos/9PSIOqR0a6hWWmktjRmgJjAQS7QkjQVpJVjBbGOFElYKRYwUuqfC6pr2Q46gEkE2WbH4uqeGGaE5HhijWNTGYNVTg2shDTEDk0S3H+AW92PWadMSAZZjBYPFQlGNleAcW17rhsmhadhwwelsCEoy9UsT/jmBPXvpy8nab7NbeD7d46VPaW0LVlLMBK2xUIriVtUN7pu2V71soTZ5slXwU3gD+Q1iHyaX8iNy57zb6/HnI++buYXoYbzYzp0zMUxhSMXZlsWHfX2dfX7FisWi353H/y7GuAnmCWKRjaWdv1jm8Xg8/gAAAP//AwD+kh3KDAUAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS/zQzbBb1HVTL+JFWvQ2Ike2EC21oKhNA2P/e0DtBol7640Y8J0ZPvMO30iEA5KO3B6Pv5ww5oXUJI9bSDChKdOU8T0XXSs6aRrnrGil/YfUZHzEr6QjRnnPvUMqUSNVgbW0l4OnIaDjjAcmWlVywstCujcCYxPwNHps5uUTpmWcI+nIX2MM85dSG8bGT/MamjRP+BCXDNGXZv58uHukf9zu9k+7y60FlyJuxkA6Ij2XWg1InZE9Vc5y6lrOqZFacB761gp3kY0xY4owNUv4/KN+mHPE7DvRCNkwKhw51yRAhtJyDwvuvx5LFxui+xKvt/B27N7ICRPpRP0d5iXdjZ/GmzwfJzzhdIS0YLrx8+EAMZCaHNN8xJRH3LBsL50myOMcq2uxu2nEmElNnp6r3QR5mNPhHavnC4KH/0HghKkvt1krtTGopJTgdC8YBqO91qL3wMzQC+QeWeuUcdwwLp1gTrbaWBOAOU9q8jLNPUy/Lxs/J1hvAzNCC1TohNLaeYbS4tAaZAG07rX1LkhrhLLBSnAA3PVovNO+GOQD+NcxYvVwTzrCOIogLdJBcE2VDUChlY5qLfWA3DsjOKnJ3RzzGNd5XaqHmPElbfxIR36FacELudvkX8eMPq+pQP1bl2K7NIfV5+qHAzhrWCMZK6I1H9dcfcQwJvQZC959Wq/5/iMRWmyiuzUljLnaQX6tfoPltVAB6zm48lTt/YAtw4EFBpqZgDpoDEoEE5RTQSvHvFbQcxWg5f1QIqQmWAxXbR4XPUrmvKQwMEsVG4A6FEgHr5TTrLUMwzuO1dNUdOAtUxgkdTgEqhwH6pSUNMgWjNCDMWL4CcnVfJwVwAFPl23dOtAhWAyaU6F4S5VznFrXGtob27teW2x96fkRU8TpJ1AfRp/mZR5ydfVu9Q7dxzWWH6fafPxljPTfbUb3s/+MqSozhjGWBbuO9VyTA8KyJjxsH1b3RnZlu6r99m8J2XAt2po8Z0h5PV7DUjfMcX4+n8/fAAAA//8DAMyRdbf1BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPpkJSFCUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaN5fY/hD9SrPaAGLQ+HT0fo44AWKLopxAgTmAhM6JaypmRNUeecE84Z/RctkLuDN9SgimtNtQRcgADMDSlxW1iNjQFJCTWElTz1VM8Dan4g5XIDR6ch98NfEAbne9Sgv11v/GuarVyuOz+aPPgO1v0QVa/TMl/Xqzv8ZbnZPmxOWQMMqTh3BjWo0LQQ3AKWVdFiLmuKZUkprgrBKDVtWTN5KnN9hNCrLh/My2W+8bGHqBuWsyInmEn0vkBGRZVWbtUA27dD2mKi6DbFF1N4+mx+oCME1LDFmcxTuxvduZv90I6uMzdRhWeINqg9vPrwAkfVoQU6BH+AEB1M3Dw8ZZeVKMlJzgQjBC3QvdI710O2vs0eugRY6ZpwMAWWYA3mkioseVFgU5SqYsJWFbNoge4g9NBddb13OvjB25jNlGcf5qx8H10/+nHI1n2E56Diqe6z6gZAC7QZ284Nu0dQ5m3rH8f0rx+7hGU1hgB9zDYq7rI/1bBDDZKq1lRJJZUSWlsoCVhiiBKkMiCMAMOZqQyX3AguiRZctZQbVdLWpghaoIcxHsZ45v/71RLb4PZ7MJcNthPHm05F68N+baCPzrokzTljMlTXTaiyueeqc9DHDySjBhEKzBQ1YMuowLw2CquykFiIQligWlaMngiz7nn8yROx1vDClnUtWtlSAsxWNSeVbYnRttDSlC1UrCJVDbXUhLQlp3UrNTFAS24SuuDNqOOvVigmgR7HPl1ntk6wX12Pvwt+YWQTfPTad0kw18HT2xBh/zvVVn5/8IOLCb+dpf02wL3v09/5/mfO5uKlj5cYpDvIptOrK0JqKzQuNK0wb0HguqootowxWVVlqSuGFujW6xcIWTKYcv2kydlTD0/ZMuidi6DjGNLkfyZQJzk/n0/mwkf+9dN25QMsD4fFHP3jmEg6NTsb4MO78m1I6Ts/xCvIM7Qn6Ox5sSs7GTieniqXgsKYGoygmHFaYi4lxbUsK9xWdStbUUOpL3bNHsG4ADpO/bZhTNNmZn/ny2WIziodh3Q8X7ye7DT7c075dckr2X5Wn4ZfSTdbae5ESbLs0+nhnBz0v97N9/f39/8AAAD//wMAsR8nGCgGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27bMBD8FYFnU+FLlKhb4LSokbYxEqMteqO4q5qIQhkUlQcC/3tB2UWa3nohiJVmd2Z2+EqCfUDSksvD4cMjhjSRFUl+KQkmNGWaMr7joq1Eq2SpjGK1Fj/JivhrfCEtqZVz3BmkEjVSBayinewdBUDDGQcmKpV72l8TaV+J9SXgo3dYjtM3jJMfA2nJdx9gfMqzrS/dMM5QxnHATZiSDS6T+bpZX9PPl9vdzfb014RTBpceSEuk41KrHqmpZUeVaTg1Fee0llpwDl3VCHOC+ZAwBjuUE9y/zYcxBUyuFaWQJaPCkOOKgE02U+7shLuXQ2axWHSV66ulvFzbV/KIkbRi9cfMU7sLN/iLh6mb/QAXpzPZ6T6rPMTxgDF5XDxZZA6DTX4MxXnSevAYElmRm7tiO9jUj/HhnVF3J/2b/5C/y9M/PKObE8J6nEMiLeEyf/pi3d4HLDZXpCWMowDZIO0F11Q1YKmtpKFaS90jd6YWnKzIegzJh3mcp2ITEv6KiwDSko92mPBE/TK6vU/o0hyzqh86Z2EbR5hdKt7856xkpWQsg+Z0mFNxi+AjuoRZ3y7O537/QIQWC2g9x4ghFVub9sUnO+1JS4xtHLfGGmu1cz1WDHsGzGpWA2rQCEpADcoo0Mowp5XtuAJb8a7PFbIimNddLAmrUWqplaTATUMVakttA45ygB6saWrVN+98LG6GjLOuYQpBUoM9UGW4pUZJSUFWtha6r2vR/2XJefucZYMBH09vZWGgARoEzalQvKLKGE4bU9W0q5vOdLrBymXO1xgDDn8Z9cW7OE5jn4pzeIp31t3OIb/3YgnSkw/0ednR1ejuMRZ5x9aHHO/zWo/H4/E3AAAA//8DAFkKilI2BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27cOBD8FYFnUeZLlKibMd5FBk7Wg8RIFnuj2K0MEZkcUJSTwJh/X1AzQeLcciOKrO7qquYLCfYJyUBuT6e/njHkhdQk+w0STGjKNGX8kYuhFYOSjTKaCan+IzXx9/idDKRTznFnkErUSBWwlo5ychQADWccmGhVqWk/L2R4IdY3gM/eYROXj5gWHwMZyCcfIH4tva1v3BxXaFKccR+WbIMrYv7Z7+7p29vD48Ph8mrBpZAbD2Qg0nGp1YTUdHKkyvScmpZz2kktOIex7YW50HzImIKdmwW+/OwPMQfMbhCNkA2jwpBzTcBmWySPdsHH76eiYrPoruD1Bm/H4YU8YyKDqH+YeSl342Z/4+LTkw1wM/nglyOpySnFE6bscbNjm3CebfYxVNcmu9ljyKQmDx+qw2zzFNPTK48+XEbf/8Hk76w7+oDV/o4MhHEUIHukk+Caqh4sta00VGupJ+TOdIKTmuxiyD6scV2qfcj4OW0qyUD+tvOCF323yR19RpfXVKT/q0vW+M3nXYQCMFKTQ4qwulz9tJuzhjWSlcuHNZ/WXL1H8AldxjLTY1qv5X+jCC020m5NCUOuDjYfqzd2OZKBGNs7bo011mrnJmwZTgyY1awD1KARlIAOlFGglWFOKztyBbbl41SQorukW20LpRlDC2qkUjJNFQOkFoWjrOUtMxa0A/HK1uphLjzreqYQJDU4AVWGW2qUlBRkazuhp64T0y+WXBPnrPgN+Hz5GhcFAD2C5lQo3lJlDKe9aTs6dv1oRt1j64rme0wB51+MeuddikuccnVdmOqVde/XUL53tS3PVx/oty2yu+i+YKpK5NaHss3XlM/n8/l/AAAA//8DAEtVZ1slBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy27cOBD8FaHPosyXKFE3Y5xgB96sB8kgG+RGka2YsEwNSMqJYfjfF9TMwvFpsReCaLGK1dVFvUAwjwgDXJ9OH54w5AQ1ZL+VOOWKUEUoOzI+tHyQopGaCSXkd6jB3+IzDNBJa5nVSAQqJNLRloxissQ51IwyR3krC6f5kWB4AeMbh0/eYrOkrxiTXwIM8LcPbvlZ7ja+sfOyuiYuM+5DyibYIuav/e6W/Hl9ON4dzqcSpgJuvIMBhGVCyQmJ7sRIpO4Z0S1jpBOKM+bGtuf6DPMhYwxmbpJ7eLvfLTlgtgNvuGgo4Rpea3AmmyJ5NAmPz6eiYrPoptTrrbxthxd4wggDr/8180x3ZWd/9ZjG1c/u6rxmkx7Stk7G5iU+Qw2nuJwwZo+bP9cp4eM4Px9Nevh4PlO26cMvtGtGt1vWkGEAJhTUsBk0zyb7JVQXjbvZY8jbxxx9SN7+F5eAGu6+VIfZ5GmJj+/m8eVs8/5/uPzJ2HsfsNrfwACUIXeiRzJxpojsnSGmFZooJdSEzOqOM6hht4Tsw7qsqdqHjD/i1hIM8NHMCc/6rqO99xltXmPp85squTrExa02V2+zZLShjaC0gNZ8WnP1GZ2PaDOWJo5xvfB9fQ/him+g3RojhlwdTL6v/jDpHgbQprfMaKONUdZO2FKcqKNG0c6hcgqd5K5zUkunpKZWSTMy6UzLxqlUoAYs0am2tFo2GdTKEjnKjkhLOemtMASF0pJJw0dWPHnzsbqbC87Ynkp0gmicHJGaGaKlEMSJ1nRcTV3Hp98sueSB0ULm8On87jYFyrkenWKES9YSqTUjvW47Mnb9qEfVY2uL5luMAeffjPrkbVzSMuXqkpDqnXWf11D+HdWWlp8+kF/bjG4W+4CxKjM2PpSnchnr6+vr6z8AAAD//wMAN4ao/4IEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUy27kNhD8FYHXiDJfokTdHNuLDBw/sDNIgtyaZMtmLFMDirJ3YfjfA2ns9WMRIBdBaJJd1dXV/UQi3CPpyPF+f/aAMU+kJDmsIcGEpkxTxndcdLXolKyU0aqR5m9SknCO30lHGuUcdwapRI1UeVZTK3tHvUfDGfdM1GrJCTcT6Z4IhMrjQ3BYjdMfmKYwRtKRP0P04+OCDaFywzj7Ko0DbuKUIbqFzOXm5Jz+fny9u7o+3JpwWh5XwZOOSMelVj1S00hLlWk5NTXntJFacO5t3QpzeBZixhRhqCZ/94bvxxwxu05UQlaMCkOeS+Ihw0LZwoS77/uFxSrR6RIv1/D62z2RB0ykE+WrmId0R24IR/eTncPgj9YvKck+jXtMOeCqxtW2eCPBWcUqoQVjpCQX4G5DxGJzWlwNS4ngWqbQS2qw91QZDtQoKamXNTRC900jelKSc0wRh3dZL4JL4zT2uXgRufiAsz0+OYtgB1wwvsAwISnJZtpmiB6GMeLZN3RzPuTapXk5PhljDnEe56nYxIw3CV7OX9+fzClhzMU15NviN5huSUcMtI6DAQOgneuxZtgzz0CzxqP2Gr0SvvHKKK+VYU4rsFx5qLntlwgpya+LhtvZOZymNzarSYZh5VC89OlkCBjzBxlJRxhH4WWLtBdcU9V6oFBLQ7WWukfuTCP4K8wO0g1m0pG+tdYy56xQhjsQBqTvmfS6N0I0Dahe1o0SWtesAeebRnDlZYMOZC9ZK0hJrtPoZ5c/t1quDbhO4z/o8iIV6Yhmdd06sFZoxVswLSquVasNNkb1wJR0FnjdgrWcQc2caYUDrg0o6+v+h0pn8SZEPA3TfoDv73DbSlfyFwfQ8h7Up+tfEtzj45juLg82ri7PdqQkX+e4bIRis1jkMUT6TS8TfTEPOexuE4JHfzF6/NlHuIxLsU6ol1w72ViKogeqGmDUtojUaqcatNIZsahxOro7TMXiMAhxmaofya62xXFytyGjy3Na+P218rjaFtcD5H5M9x82ybvCPgtQCcnZ0hmPD4dttHLU3rfoNadC8ZoqYzhtTd1Q27TWWN1i7RbFrua8n3PxFX1I6PJa8YsXV8yTW3R3P4vx6oIXi3K2eG172GKrtP9riT2X5B5hmhPer9u6eyKbGDGtyKfzYRQ38SIMQ5jQjdFPpKuVairGhHhh+N/3jKqYEvr5+fn5XwAAAP//AwCgOTEOGwYAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS/zQzbBb1HVTL+JFWvRGcUa2EC21oKhNA2P/e0DtBol7600Y6J0ZPvPOvJHoD0g6cns8/nLCmBdSkzxuIcGEpkxTxvdcdkJ3zDVWSN4y8Q+pyfiIX0lHjAqBB4dUokaqgLW0l0OgAOg448BEq0pO/7KQ7o34sQE8jQGbefmEaRnnSDry1xhh/lJq+7EJ07xCk+YJH+KSfQylmT8f7h7pH7e7/dPu8teCSxE3I5COKOTCKXSUSWeoEopRJ9BQVBDc4Kw2Hi6yMWZM0U/NAp9/1Ic5R8yhE42QDaPCkXNNwGdfWu79gvuvx9LFhui+xOstvH12b+SEiXSi/g7zku4mTONNno8TnnA6+rRgugnz4eBj6eWY5iOmPOKGZXvpNPk8zrG6FrubRoyZ1OTpudpNPg9zOrxj9XxB8PA/CJww9aQjkrVSG4NKSumd7gVDMDpoLfrgmRl6gTwga50yjhvGpRPMyVYba8AzF0hNXqa599Pvy8bPCdZbYEZogQqdUFq7wFBaHFqDDLzWvbbBgbRGKAtWeuc9dz2a4HQoBvngw+sYsXq4Jx1hHAVIi3QQXFNlwVPfSke1lnpAHpwRnNTkbo55jOu8LtVDzPiSNn6kI7/6acELudsUXseMIa+pQP1bl2K7NMMacvXDAZw1rJGMFdGaj2uuPiKMCUPGgnef1mu+/0iEFpvobk0JY652Pr9Wv/nltVDxNnDvylN1CAO2DAcGzGtmADVoBCXAgHIKtHIsaOV7rsC3vB9KhNQEi+Gqi8fBgrBqoEbalqpWC+p4MNT32DMwPPTSvONYPU1F54NlCkFShwNQ5binTklJQbbeCD0YI4afkFzNx1kBDHi6bOvWgQawCJpToXhLlXOcWtca2hvbu15bbEPp+RFTxOknUB/GkOZlHnJ19W71Dt3HNZaLU20+/jJG+u82o/s5fMZUlRn7MZYFu471XJMD+mVNeNgOVvdGdmW7qv12t4RutDW2Js/Zp7wer2FpGy21OZ/P528AAAD//wMAx6rPOvUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27jNhSGX0XgWlR4EyVqlzod1MhkYkzcadEdRR45RGTSoKhMg8DvPqDsIM4MuuhOOOJ/Lt/5yVfk9R5Qh64Ph9+fwacJlSi5JcQIk5hITOiW8o7JjqiqbduGUfYPKpG7hRfUoUYYQ40CzEECFpbUuOeDwdaCooRawmqRc+rdhLpXpF1l4dkZqML0DeLkgkcd+st5G77n2tpVZgyzrWIYYe2npL3JzXxZr27x5+vN9n5zOjXBlMWVs6hDAihTAhQmXDVYMEGwYtBgENaoQbWy0fYkcz5B9HqsJvv0Xt+G5CGZjlWMVwQzhY4lsjrp3HKvJ9i+HHIXC6KbHC+X8PLZvaJniKhj5RvMU7orM7qr/dTPbrRXY9jtnN+tgh/cbo465cIlOsRwgJgcLHB+c17Hl89ht8v50Cc9ToBKtIW4d16Ppx83MOh5TA9hjguZyT791xnUIT2ngEq0kBzHpWxxHmY1OvDpF+1F4fuHYjPqNIS4/7CkhxP79f9Af6fNo/NQrG9QhwgFZnkLeGBUYtFajXXNFZaSywGoUQ2jqESr4JPzc5inYu0T7M7UPvR3Hc2jS2DSHPNMf8tstk0MdjapeF8wJRWpOCFZNKfDnIqvYF0EkyAPsY3zOd9PEibZIvrkRvgFz2qOEXwqNjo9Fn/o6RF1SOnWUK200loaM0BNYCCWaEkaC9JKsILZxgolrBSKGCl0T4XVNe2HHEElgmyyYvE114JzzgxWVgxY1I3B2vYNplwYRikTgvQf4Bb3Y9Zp0xIBlmMFg8VCUY2V4BxbXuuGyaFp2HDB6WwISjL1SxP+OYE9e+nLydpvs1t4Pt3jpU9pbQtWUswErbFQiuJW1Q3um7ZXvWyhNnmyVfBTeAP5DWIfJpfyI3LnvNvr8ecj75u5hehhvNjOnTMxTGFIxdmWxYd9fZ19fsWKxaLfncf/Lsa4CeYJYpGNpZ2/WObxeDz+AAAA//8DACYP7R0MBQAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPpkJSFCUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaP5ePPeG/5AvdoDatDycPh0hD4OaIGim0KMMIGJwIRuadEw0VCSs4oVhIt/0QK5O3hDDaq41lRLwAUIwNyQEreF1dgYkJRQQ1jJU0/1PKDmB1IuN3B0GnI//AVhcL5HDfrb9ca/ptnK5brzo8mD72DdD1H1OoH5ul7d4S/LzfZhc8oaYEjFuTOoQRwokxwkJoWsMGecYMmgwsCNllbWolLmVOb6CKFXXT6Yl8t842MPUTcsZ0VOMJPofYGMiipBbtUA27dDQjFRdJviiyk8fTY/0BECatjiTOap3Y3u3M1+aEfXmZuowjNEG9QeXn14gaPq0AIdgj9AiA4mbh6esgskSnKSM8EIQQt0r/TO9ZCtb7OHLi2sdE04mAJLsAZzSRWWvCiwKUpVMWGrilm0QHcQeuiuut47HfzgbcxmyrMPc1a+j64f/Thk6z7Cc1DxVPdZdQOgBdqMbeeG3SMo87b1j2P6149d2mU1hgB9zDYq7rI/1bBDDZKq1lRJJZUSWlsoCVhiiBKkMiCMAMOZqQyX3AguiRZctZQbVdLWpghaoIcxHsZ45v/7FYhtcPs9mAuC7cTxplPR+rBfG+ijsy5Jc86YDNV101bZ3HPVOejjB5JRgwgFZooasGVUYF4bhVVZSCxEISxQLStGT4RZ9zz+5IlYa3hhy7oWrWwpAWarmpPKtsRoW2hpyhYqVpGqhlpqQtqS07qVmhigJU8m3QRvRh1/tUIxCfQ49uk6s3Va+9X1+LvgF0Y2wUevfZcEcx08vQ0R9r9TbeX3Bz+4mPa3s7TfBrj3ffo73//M2Vy89PESg3QH2XR6FZjC8gqwaVmBOSdwOj3DWjC1rkXNkoy3Xr9AyJLBlOsnTc6eenjKlkHvXAQdx5Am/zMtdZLz8/lkLnzkXz9tVz7A8nBYzNE/jomkU7OzAT68K9+GlL7zQ7xaeV7tCTp7BnZlJwPH01PlUlAYU4MRFDNOS8ylpLiWZYXbqm5lK2oo9cWu2SMYF0DHqd82jGnazOzvfLkM0Vml45CO54vXk51mf84pv4K8ku1n9Wn4lXSzleZOlCTLPp0ezslB/+vdfH9/f/8PAAD//wMAGp/mGygGAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cNhD9K8KcRZmkKErUzVin6MJNvEgWadEbxRnFhGVqQVFOAmP/e0DtBol764UgBnwz72P4CsE+E/Rwezq9e6GQFigh+a0kudSMa8bFUdS91L1oKt3JRhv+L5Tg7+k79NAq54QzxGrSxBTyhg316BgiGcEFctmo3NN+WaB/BesrpBfvqJqXzxQXPwfo4W8fcP6aZ1tfuWlesYrzRPuwJBtcJvNhv7tnf90ejg+Hy6uFlgyuPEIPioQ0igzjtWmZkoozI6llpNCZ0XS6tXiB+ZAoBjtVCz79mo9zCpRcLytZV5xJA+cS0CabKQ92oeP3U2axWXSX6+VW3q79K7xQhF6WP828tLtxk795XobVT3hzOZNdnrLKU5xPFJOnzZNN5jTZ5OdQXCftJk8hQQkPn4rDZNM4x+c3Rn266N//D/nHPP3dN3JrItzNa0jQg6gNlPDeukcfqNjfQQ9ckMS6IzZKoZnq0DLb1IZpXeuRhDOtFFDCbg7Jh3Vel2IfEn2JmwDo4Q87LXShfhvdo0/k0hqzqn903oVDnHF1qfjlv+AVr2rOM2hNpzUVHwl9JJco6zvG9drvPxCp5QbarTFSSMXBpsfiT7s8Qg/Gdk5YY4212rmRGk4jR241b5E0akIlsUVlFGpluNPKDkKhbcQw5gqUQDnuYtuwUdVcSeKMj9IwNVrJhq6VbJCtEELrpnVvfSwepoyzruOKsGaGRmTKCMuMqmuGdWNbqce2leNvllzTFzwbjPRy+SsbA43YEWrBpBINU8YI1pmmZUPbDWbQHTUbg3uKgabfjHrvXZyXeUzFdXmKN9Z9XEP+78W2SF99YN+2jO5m90SxyBlbH/J6X2M9n8/nHwAAAP//AwDZhTs/NgQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4owZ1HmlyhRN2OdoAs39SJZpEVvFDmKCcvUgqScGMb+94LaDVKfil4IYsR3+M4zQ71BMM8IA9yeTh9eMOQENWS/hTjlilBFKDsyMXA1sLZRPZW6a/+GGvw9vsIAnbSWWY1EoEIiHW3JKCZLnEPNKHOUt7LkNN8SDG9gfOPwxVtslvQVY/JLgAH+9MEt38vdxjd2XlbXxGXGfUjZBFvM/LHf3ZPfbw/Hh8PlVMJUxI13MIBExrVETajQHZFcUqI5dgSls3rSveqMu8h8yBiDmZvknn7d75YcMNuBN1w0lHAN5xqcyaZYHk3C4+upuNgQ3ZV4vYW37fAGLxhh4PVPmJd0N3b2N89pXP3sbi5rNukpbetkbF7iK9RwissJY/a48blNCZ/H+fVo0tPHy5myTR9+oF0zut2yhgwDMKGghg3QPJvsl1BdPe5mjyFvH3P0IXn7X7kE1PDwpTrMJk9LfH7Xjy8XzPv/QfmTsY8+YLW/gwEoQ+5Ej2TiTBHZO0NMKzRRSqgJmdUdZ1DDbgnZh3VZU7UPGb/FrSQY4KOZE1783Ub76DPavMZS51+qzNUhLm61ufrVS0Yb2ghKi2jNpzVXn9H5iDZjKeIY12u+r+8lXPFNtFtjxJCrg8mP1W8mPcIA2vSWGW20McraCVuKE3XUKNo5VE6hk9x1TmrplNTUKmlGJp1p2TiVCNSAZXSqbVrbzhReLUE6CiKtEkQLIwgy17GxxZGa6R3H6mEuOmN7KtEJonFyRGpmiJZCECda03E1dR0vup9IrvPAaAHs8OXy7jYHyrkenWKES9YSqTUjvW47Mnb9qEfVY2uL53uMAed/gfrkbVzSMuXqOiHVO3Sf11D+HdU2Ld99ID+2Ht0t9gljVXpsfChP5drW8/l8/gcAAP//AwAKlhXBggQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwY7cKBD9FYuz8QDG2Pg26tlVWpPstJJRstobpoo0igdaGE8SjfrfV7g7Sia33NCDV/XqveKFBPOEZCS3p9NfzxjyQmqS/QYJJhRlijL+yNtRqJF3jRpk1w/6P1ITf4/fyUh6aS23GmmLCqkE1tGpdZYCoOaMAxOdLDXN54WML8T4BvDZW2zi8hHT4mMgI/nkA8SvpbfxjZ3jCk2KM+7Dkk2wRcw/+909fXt7eHw4XF4tuBRy44GMRCIXWqKmrNU9lUIyqgX2FCVY7fSgegMXmg8ZUzBzs8CXn/0h5oDZjqIRbcOo0ORcEzDZFMmTWfDx+6mo2Cy6K3i9wdtxfCHPmMgo6h9mXsrd2Nnf2Pj0ZALcOB/8ciQ1OaV4wpQ9bnZsE86zyT6G6tpkN3sMmdTk4UN1mE12MT298ujDZfT9H0z+ztijD1jt78hIGEcB7YDUCa6oHMBQ07WaKtUqh9zqXnBSk10M2Yc1rku1Dxk/p00lGcnfZl7wou822aPPaPOaivR/Vckav/m8i1AARmpySBFWm6ufdnPWsKZl5fJhzac1V+8RfEKbscz0mNZr+d8oQomNtFtTwpCrg8nH6o1ZjmQk2gyWG220Mcpahx1Dx4AZxXpABQpBCuhBaglKamaVNBOXYDo+uYIU3SXdaluorp/aaeiQOhwclWZAOijXUauB9cw4kCBe2Vo9zIVn7MAkQks1OqBSc0O1bFsKbWd6oVzfC/eLJdfEOSt+Az5fvsamQAEMCIpTIXlHpdacDrrr6dQPk57UgJ0tmu8xBZx/Meqdtyku0eXqujDVK+ver6F872pbnq8+0G9bZHfRfsFUlciND2Wbrymfz+fz/wAAAP//AwBHtmfAJQQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAs6jwS/zQzbBb1HBTL2KjLXobcUaxEK20oKhNA8P/vaB2i8S99SIIA87wnWdeziub4UisYzen009nmvPKapbHPaSEslxYLuSz1J2ynWkbb5TxPvzFajY+0DfWMWdilDEQ12SJGxQt7/UQOSIFKSQK1ZpSEz6vrHtlMDZI5zFSs6y/U1rHZWYd+2Occfla7oaxidOyYZOWie7nNcMci5jf7m8f+K83h+fHw+XUSmtJbkZkHQvBOYyt5KF8jO4VD70HrlRPZHsTpBWXtHHOlGaYmhW/fL8flzxTjp1qlG4EV4G91QwhQ5Hcw0rP305FxY7orsTrPbz/dq/sTIl1qv4X5qXchziNH9atn+hM0wnSSulDXI5HmJHV7JSWE6U80o7lTKlf1jEXojE68F5K9OBM69DrVmmHWhB64VoMGMAaO6jYtgP2PjgzGFTSoCVjesdqtoObJsjjMldX7bfTSHNmNXt8qg4T5GFJx3fony5E7/8H0CKbdUyLVlvnyGitIdheCUJno7WqjyDc0CuSkUQbjAvSCamDEkG31nmHIEJkNfsI8WWcqbq/Yx0TkhRqT3xQ0nLjETi0OnBrtR1IxuCUZDW7XeY8ztuyrdX9nOlz2vtlHfsZppUund6k+DJminlLBcKftpjxkBbcYq6+G0CKRjRalJ4et3zacvWJcEwUMxUcz2m71vtPirJqT7rdUqI5VwfIL9UvsL4UhuCjhAABwMY4UCtoECjACodk0RIahQ5NMGhNENEa6KVBaGU/lAirGRW/VbvFXYhRhmHgwaHiZgjEYegVp9a46ACs8fSOY/U4lTyIXhhCzQMNyE2QwIPRmqNuwSk7OKeGH5BczSJFAYx0vjzWXYFF9IRWcmVky00IkvvQOt4734feempj0fxAaabpB1Afx5iWdRlydfVa9Q7dp20uC6faffd1nPnf+4zulviFUlVmDONc3td1rG81OxKsW6Ljvq+6V3Yoj6t63teW8o1U1tbsKUPK2+ka1m3jvHNvb29v/wAAAP//AwB1G8B29AQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/cNhDHv4rAsyjz/dDNsFvUdVMv4kVa9MbHyBaipRYUtWlg7HcPqN0gcW+9EMSA8+Bv/jNvKLkDoB7dHo+/nCCVBbWojJuJEaYwUZjQPeU9U72QnSGCU2n+QS0aH+Er6pEWIdBgAXNQgEUkEns+BBwjWEpoJEyKGtO9LKh/Q27sIpzGAN28fIK8jHNCPfprTHH+UnO7sQvTvMYuzxM8pKW4FGoxfz7cPeI/bnf7p93l1QJLde7GiHpkrdYxSIptPQT3DFtvHGbMAygvLFXk4jamAjm5qVvi5x/541wSlNCzjvGOYGbRuUXRFVdL9m6B/ddjrWJDdF/t7Wberv0bOkFGPWu/w7yEuwnTeFPm4wQnmI4uL5Bvwnw4uBRRi455PkIuI2xYtp9OkyvjnJprsrtphFRQi56em93kyjDnwztWzxcED/+DwAmyRz3iRHKlNQjOubPKMwJRq6AU88ERPXgGNACRVmhLNaHcMmK5VNro6IgNqEUv0+zd9Puy8bOMeBOJZoqBAMuEUjYQ4AYGqYFEp5RXJtjIjWbCRMOddY5aDzpYFapAPrjwOiZoHu5RjwgFFrkBPDCqsDDRYSe5xUpxNQANVjOKWnQ3pzKmdV6X5iEVeMkbP9SjX920wIXcbQ6vY4FQ1lyh/q1qsl2e4xpK80MBlHSk46QyelrLcS3NR4hjhlCg4t3n9RrvPy5Msc3pbs0ZUml2rrw2v7nltVJxJlBn61dVCANIAgOJxCmiI6ioIAoWdRRWRCUsCUo4T0V0kvqhWlCLoAqu2TRuiDSKS4epBo+FdB4bIQR2SkrFSIyR2nccm6ep+rlgiIDIsYUhYmGpw1ZwjiOXTjM1aM2Gn5BcxUdJBRzhdJnWrQIVo4GoKGaCSiyspdhYqbHXxluvDMhQK3iEnGD6CdSHMeR5mYfSXLXbvEP3cU114zSbjr+MCf+79eh+Dp8hN7XHbkx1wK5tPbfoAG5ZMxy2hdW/oV2drma/7S1mOsqUatFzcbmsx6uZy04brc/n8/kbAAAA//8DAMMGw8r1BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUXW/jKBSG/4rFtXEBY2x81013tFGn02ianV3tHYbjFNWBCHBnqyr/fYSTqumM9mJvLOuY8/W8r3lFTu0B9ej6cPj9GVyKqETJLiFGmMBEYEK3tO6Z6HlTdS2lTUf/QSWyt/CCetRyramWgGsQgLkhDR7qUWNjQFJCDWENzzXVLqL+FSlbGXi2Giofv0GI1jvUo7+sM/577q1spSc/myr4CdYuJuV0HubLenWLP19vtveb06kIMSdX1qAeSdm2RjcUy/zg9cCwHDqFGRsAxMAlFeSUZl2C4NRURfP03t/45CDpnlWsrghmEh1LZFRSeeRBRdi+HPIUC6KbHC+X8PLav6JnCKhn5RvMU7krPdmrfRxmO5mrye921u1W3o12NweVcuMSHYI/QEgWFji/WafCy2e/2+V66JOaIqASbSHsrVPT6cMNjGqe0oOfw0Immqf/OoN6pObkUYkWktO0tC3Oy6wmCy79knvR+P6h2EwqjT7sP4j0cGK//h/o75R+tA6K9Q3qEaHATN0BHhkVmHdGYdXUEgtRixGoli2jqEQr75J1s59jsXYJdmdqH+a7DvrRJtBpDnmnv0U22yZ4M+tUvAtMSUWqmuRJ7ud0mFPxFYwNoBPkJbZhPtf7KYUJtiR9shP8gmc1hwAuFRuVHos/VHzMOFSnqZJKKiW0HqEhMBJDlCCtAWEEGM5Ma7jkRnBJtOBqoNyohg5jjqASQTZZsfgaajaKWkmsx85gLroGSzXUWFLWSC1ZI4bxA9zifsp5SneEg6mxhNFgLqnCktc1NnWjWibGtmXjBaezISjJ1C9N+GcEc/bSl5O133Y38Hz6j5c5hTEdGEEx47TBXEqKO9m0eGi7QQ6ig0bnzVbeRf8G8huEwUeb8iVyZ53dq+nnI+/K3EJwMF2oc2d18NGPqTjbsvig19fZ5VusWCz63Tr872KMG6+fIBTZWMq6CzGPx+PxBwAAAP//AwA/OrCDDAUAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUwW7bOBD9FYFnUyEpihIF7MFwWqyRpjESd3exN4ocxkRk0aAop0GRf19Qlmun6GEvgjDkzLx57w1/oF7tATVoeTh8OkIfB7RA0U0hRpjARGBCt7RomGi4yFnJK0H4v2iB3B28oQZVXGuqJeACBGBuSInbwmpsDEhKqCGs5Kmmeh5Q8wMplxs4Og25H/6CMDjfowb97XrjX1Nv5XLd+dHkwXew7oeoep3AfF2v7vCX5Wb7sDndGmBIybkzqEFSVpXRJcUyfXjRMizbWmHGWgDRckkFOaW5PkLoVZcP5uXS3/jYQ9QNy1mRE8wkel8go6JKkFs1wPbtkFBMFN2m+GIKT7/ND3SEgBq2OJN5KnejO3ezH9rRdeYmqvAM0Qa1h1cfXuCoOrRAh+APEKKDiZuHp+wCiZKc5EwwkoDfK71zPWTr2+yhSwMrXRMOpsASrMFcUoUlLwpsilJVTNiqYhYt0B2EHrqrqvdOBz94G7OZ8uxDn5Xvo+tHPw7Zuo/wHFQ85X1W3QBogTZj27lh9wjKvG3945jO+rFLs6zGEKCP2UbFXfanGnZJF1VrqqSSSgmtLZQELDFECVIZEEaA4cxUhktuBJdEC65ayo0qaWtTBC3QwxgPYzzz//0KxDa4/R7MBcF24njTqWh92K8N9NFZl6Q535gM1XXTVNlcc9U56OMHklGDCAVmihqwZVRgXhuFVVlILEQhLFAtK0ZPhFn3PP7kiVhreGHLuhatbCkBZquak8q2xGhbaGnKFipWkaqGWmpC2pLTupWaGKAlN2m64M2o469WKCaBHsc+bWe2TmO/uh5/F/zCyCb46LXvkmCug6e3IcL+d6qt/P7gBxfT/HaW9tsA975Pp/P+z5zNyUsfLzFIe5BNq2dKSgjhGhNVl5iXosWKVwWmINqqJEChSDLeev0CIUsGU66fNDl76uEpWwa9cxF0HEPq/M801EnOz+eVufCRf/20XfkAy8NhMUf/OCaSTsXOBvjwrnwb0vWdH+LVyPNoT9DZM7ArOxk4np4ql4LCmBqMoJhxWmIuJcW1LCvcVnUrW1FDqS92zR7BuAA6TvW2YUzdZmZ/58tliM4qHYe0PF+8nuw0+3O+8ivIK9l+Zp+aX0k3W2muREmy7NPp4Zwc9L/ezff39/f/AAAA//8DAHaYtSMoBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTQW/cNhCF/4rAsyiTFEWJuhnrFF24iRfJIi16G3FGMWGZWlCUk8DY/x5Qu4GT3nohhIGG8943j68swDOxnt2eTu9eKKSFlSz5raSEMlwYLuRR1r0yfSOrphG6M82/rGT+nr6znrXaOeks8ZoMcY2i4UM9Oo5IVgqJQjU63wlfFta/MvAV0ot3VM3LZ4qLnwPr2d8+4Pw1zwZfuWlesYrzRPuwJAgui/mw393zv24Px4fD5a+FltxceWQ9s7Zt0TWS23zoelDcDh1wpQYiM2grjbi0+ZAoBpiqBZ/e5uOcAiXXq0rVleDKsnPJEBJkyQMsdPx+yio2RHe5Xm7l7bN/ZS8UWa/KnzAv1924yd88L8PqJ7y5nAmWp+zyFOcTxeRpY7LZnCZIfg7FddJu8hQSK9nDp+IwQRrn+PwbqE8X//v/Yf+Yp7/7Rm5NhLt5DYn1TNaWlew9uEcfqNjfsZ4JSQrrjviopOG6Q+DQ1JYbU5uRpLOtkqxkuzkkH9Z5XYp9SPQlbgZYz/6AaaGL9NvoHn0il9aYXf1jchYOccbVpeKNvxSVqGqRRT6s6bSm4iOhj+QSZX/HuF7v+0+LMmpr2q0xUkjFAdJj8ScsjxkKdE6CBQtgnBupETQKFGBEi2TQEGqFLWqr0WgrnNEwSI3QyGHMFVYyyusutoQ16JpukC0ni8C1GiQHJztOVA/KEIxa5FC8cSweptwHrhOasOaWRuTaSuBW1zXHuoFWmbFt1fgLkuv2pciAkV4ub2VTYBA7QiO50rLh2lrJO9u0fGi7wQ6mo8ZlzfcUA02/gHrvXZyXeUzFNTzFb+g+riG/92IL0lcf+LdtR3eze6JY5B2DDzne17Wez+fzDwAAAP//AwBiE6abNgQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU0W7cKhD9FYtn4wDG2Pgt2rS6q9w2q3bVVn0bw7hBcfAKcNoo2n+vsLdK83R1X0Zo4Axnzhx4IR4ekfTk+nR694Q+RVKS5NaUYEJRpijjR173QvUNrxrZMaXb76Qk7hafSU9aaQw3GmmNCqm0rKFDPRpqLWrOuGWikbkm/IikfyHgKotPzmA1xy8Yops96clX5+38M98NrjLTvNgqzBPufUzgTSbzcb+7pf9eH453h+1UxJjBlbOkJ1q3rTUNpzoHWQ+C6qEDKsSAqAapuWIbzPmEwcNURfvwer+dk8dkelGJumJUaHIuiYUEmfIAEY/Pp8xilegm58s1vS77F/KEgfSi/CPmVu7KTO7qMQ6Lm+zVFhPEh7jGEUyawzMpySnMJwzJ4arPdYz4OEzPR4gP77czeRnf/UKzJLS7efGJ9ITXipRkFWiaILnZFxeOu8mhT+tmCs5HZ/6rVk1Kcve5OEyQxjk8vpnH503m/f9Q+QOYe+ex2N+QnjCOwtYd0lFwRWVngUJTa6pUrUbkRreCk5LsZp+cX+YlFnuf8EdYWyI9eQ9TxI3fdTD3LqFJS8h9flPZV4cw28Wk4nWWnFWsqllmcrek05KKT2hdQJMwN3EMy6Xel7cQocQK2i0hoE/FAdJ98Q/E+9w5dIaDBg2gjBmxYTgyy0Cx1qKyCq0UtrVSS6ukZkZJGLi00PBhzBlSEszWKTa3Am90yxQFZWoqrVYUYJQUAczQqrZudUa86ljcTRkHpmMSbU01jpZKzYFqWdfU1g20Qo1tK8a/JLn4gbMssMWn7d2tDJS1HVrFqZC8oVJrTjvdtHRou0EPqsPGZAa3GDxOfwn1wZkwx3lMxcUhxRvpPi0+/x3F6pafztNf64xuZvOAocgzBufzU7mM9Xw+n38DAAD//wMAXVqRGIIEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTT2/cIBDFv4rFeXEAY2x8izatukrTrJqorXrDzDiL4sAK4/xRtN+9wrtVkt56sayxH/Pm94ZX4s0Dko6c7/efHtGniaxIcktJMKEoU5TxW151QnU1L+tatLzRv8mKuEt8IR1ppLXcaqQVKqQSWE37arAUADVnHJioZT7T3E2keyXGlYCPzmIZph8YJxc86chP5yE85d7GlXYMM5QxjLjxUzLeZjPfNutL+vV8e3u9Pf414ZTFpQPSEa2bBmzNqc4PWfWC6r41VIgeUfVSc8WOMucTRm/GcoL7t/4QksdkO1GKqmRUaHJYETDJZMu9mfD2ZZ9dLIgucn21lJfX7pU8YiSdWP2FeTzuzI7uzIaHB+PhbHDeTTuyIvsY9hiTwwXHMuE4muSCL05N1qNDn8iKXN8U29GkIcSHD4xujqNv/mPyK2N3zmOxuSAdYRwFVC3SQXBFZQuGmrrSVKlKDcitbgQnK7IOPjk/h3kqNj7hXVxcko58NuOER3/n0e5cQpvmmK3/UjlrfHZpHSAXcu9tDDDbVLzh5qxkZcXyx+s57edUfEdwEW3CPNNtnE/H/yMRSiyi9Rwj+lRsTdoVX8y0yyBMa7nRRhujrB2wZjgwYEaxBlCBQpACGpBagpKaWSVNzyWYmvdDrmTfOd1iWSg0rGGqQdrKvNUWDO11P1DAQbRaDapts+INa3E9Zp2xLZMIFdU4AJWaG6plVVGoatMINTSNGN4hOSXOWeYN+Hi8GosDBdAiKE6F5DWVWnPa6rqhfdP2ulct1jY7uMTocXwH6srZGKYwpOK0MMUHdN9nn693sSzPk/P0eYnsIth7jEWO3Dift/mU8uFwOPwBAAD//wMAq0uqsyUEAAA=

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
public sealed class DynamicsController(IDynamicsService dynamics) : Controller
{
public async Task<IActionResult> Story(int projectId)
{
var model = await dynamics.GetStoryDynamicsAsync(projectId);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Character(int id)
{
var model = await dynamics.GetCharacterArcAsync(id);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Relationship(int id)
{
var model = await dynamics.GetRelationshipEvolutionAsync(id);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Knowledge(int id)
{
var model = await dynamics.GetKnowledgeTimelineAsync(id);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunChecks(int projectId, int? bookId, int? sceneId)
{
await dynamics.RunContinuityChecksAsync(projectId, bookId, sceneId);
return RedirectToAction(nameof(Story), new { projectId });
}
}

View File

@ -224,6 +224,15 @@ public interface IExportRepository
Task<ExportPacket> GetSearchResultsAsync(int projectId, string query);
}
public interface IDynamicsRepository
{
Task<(Character? Character, IReadOnlyList<CharacterArcPoint> ArcPoints, IReadOnlyList<RelationshipEvent> RelationshipEvents, IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents, IReadOnlyList<DynamicsIssue> Issues)> GetCharacterArcAsync(int characterId);
Task<(CharacterRelationship? Relationship, IReadOnlyList<RelationshipProgressionPoint> Events, IReadOnlyList<DynamicsIssue> Issues)> GetRelationshipEvolutionAsync(int characterRelationshipId);
Task<(Character? Character, IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents, IReadOnlyList<DynamicsIssue> Issues)> GetKnowledgeTimelineAsync(int characterId);
Task<(IReadOnlyList<CharacterPairingSummary> Pairings, IReadOnlyList<DynamicsIssue> Issues)> GetStoryDynamicsAsync(int projectId);
Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId);
}
public sealed class TimelineData
{
public Project? Project { get; init; }
@ -838,6 +847,54 @@ public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) :
}
}
public sealed class DynamicsRepository(ISqlConnectionFactory connectionFactory) : IDynamicsRepository
{
public async Task<(Character? Character, IReadOnlyList<CharacterArcPoint> ArcPoints, IReadOnlyList<RelationshipEvent> RelationshipEvents, IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents, IReadOnlyList<DynamicsIssue> Issues)> GetCharacterArcAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.Dynamics_CharacterArc", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure);
return (
await result.ReadSingleOrDefaultAsync<Character>(),
(await result.ReadAsync<CharacterArcPoint>()).ToList(),
(await result.ReadAsync<RelationshipEvent>()).ToList(),
(await result.ReadAsync<CharacterKnowledgeItem>()).ToList(),
(await result.ReadAsync<DynamicsIssue>()).ToList());
}
public async Task<(CharacterRelationship? Relationship, IReadOnlyList<RelationshipProgressionPoint> Events, IReadOnlyList<DynamicsIssue> Issues)> GetRelationshipEvolutionAsync(int characterRelationshipId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.Dynamics_RelationshipEvolution", new { CharacterRelationshipID = characterRelationshipId }, commandType: CommandType.StoredProcedure);
return (
await result.ReadSingleOrDefaultAsync<CharacterRelationship>(),
(await result.ReadAsync<RelationshipProgressionPoint>()).ToList(),
(await result.ReadAsync<DynamicsIssue>()).ToList());
}
public async Task<(Character? Character, IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents, IReadOnlyList<DynamicsIssue> Issues)> GetKnowledgeTimelineAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.Dynamics_KnowledgeTimeline", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure);
return (
await result.ReadSingleOrDefaultAsync<Character>(),
(await result.ReadAsync<CharacterKnowledgeItem>()).ToList(),
(await result.ReadAsync<DynamicsIssue>()).ToList());
}
public async Task<(IReadOnlyList<CharacterPairingSummary> Pairings, IReadOnlyList<DynamicsIssue> Issues)> GetStoryDynamicsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.Dynamics_StoryDashboard", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
return ((await result.ReadAsync<CharacterPairingSummary>()).ToList(), (await result.ReadAsync<DynamicsIssue>()).ToList());
}
public async Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.Dynamics_RunContinuityChecks", new { ProjectID = projectId, BookID = bookId, SceneID = sceneId }, commandType: CommandType.StoredProcedure);
}
}
public sealed class SceneDependencyRepository(ISqlConnectionFactory connectionFactory) : ISceneDependencyRepository
{
public async Task<SceneDependencyLookupData> GetLookupsAsync()

View File

@ -1365,6 +1365,67 @@ public sealed class ExportPacket
public IReadOnlyList<StoryBibleSearchResult> SearchResults { get; init; } = [];
}
public sealed class CharacterArcPoint
{
public int SceneID { get; set; }
public string BookTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public int GlobalSceneIndex { get; set; }
public bool IsPov { get; set; }
public int? EmotionalWeight { get; set; }
public int? Tension { get; set; }
public int? Romance { get; set; }
public int? Action { get; set; }
public int? Darkness { get; set; }
public int? HopeLightness { get; set; }
public int? OverallIntensity { get; set; }
public string? RoleInSceneTypeName { get; set; }
public string? PresenceTypeName { get; set; }
public string? EmotionalState { get; set; }
public string? AppearanceNotes { get; set; }
public string? KeyNotes { get; set; }
}
public sealed class RelationshipProgressionPoint
{
public int RelationshipEventID { get; set; }
public int CharacterRelationshipID { get; set; }
public int SceneID { get; set; }
public string BookTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public int GlobalSceneIndex { get; set; }
public string RelationshipStateName { get; set; } = string.Empty;
public int? Intensity { get; set; }
public bool IsKnownToOtherCharacter { get; set; }
public string? Description { get; set; }
}
public sealed class CharacterPairingSummary
{
public int CharacterAID { get; set; }
public string CharacterAName { get; set; } = string.Empty;
public int CharacterBID { get; set; }
public string CharacterBName { get; set; } = string.Empty;
public int SharedSceneCount { get; set; }
public string? FirstSceneLabel { get; set; }
public string? LastSceneLabel { get; set; }
}
public sealed class DynamicsIssue
{
public string Category { get; set; } = string.Empty;
public string SeverityName { get; set; } = "Info";
public string Title { get; set; } = string.Empty;
public string? Detail { get; set; }
public int? SceneID { get; set; }
public int? EntityID { get; set; }
public string? EntityType { get; set; }
}
public sealed class AnalyticsBusyScene
{
public int SceneID { get; set; }

View File

@ -35,6 +35,7 @@ public class Program
builder.Services.AddScoped<IStoryBibleRepository, StoryBibleRepository>();
builder.Services.AddScoped<IWriterWorkspaceRepository, WriterWorkspaceRepository>();
builder.Services.AddScoped<IExportRepository, ExportRepository>();
builder.Services.AddScoped<IDynamicsRepository, DynamicsRepository>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();
@ -49,6 +50,7 @@ public class Program
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IDynamicsService, DynamicsService>();
var app = builder.Build();

View File

@ -178,6 +178,15 @@ public interface IExportService
Task<(string FileName, string ContentType, string Content)> ExportSearchResultsAsync(int projectId, string query, string format);
}
public interface IDynamicsService
{
Task<CharacterArcViewModel?> GetCharacterArcAsync(int characterId);
Task<RelationshipEvolutionViewModel?> GetRelationshipEvolutionAsync(int characterRelationshipId);
Task<KnowledgeTimelineViewModel?> GetKnowledgeTimelineAsync(int characterId);
Task<StoryDynamicsViewModel?> GetStoryDynamicsAsync(int projectId);
Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId);
}
public sealed class ProjectService(IProjectRepository projects, IBookRepository books) : IProjectService
{
public Task<IReadOnlyList<Project>> ListAsync() => projects.ListAsync();
@ -1585,9 +1594,12 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
public sealed class ContinuityValidationService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
IAssetRepository assets,
ICharacterRepository characters,
IWarningRepository warnings) : IContinuityValidationService
IWarningRepository warnings,
IDynamicsRepository dynamics) : IContinuityValidationService
{
public async Task<WarningDashboardViewModel?> GetDashboardAsync(
int projectId,
@ -1632,6 +1644,7 @@ public sealed class ContinuityValidationService(
public async Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId)
{
var summary = await warnings.ValidateProjectAsync(projectId);
await dynamics.RunContinuityChecksAsync(projectId, null, null);
await warnings.FinaliseSceneDependencyWarningsAsync(projectId, null);
return summary;
}
@ -1642,6 +1655,7 @@ public sealed class ContinuityValidationService(
var book = await books.GetAsync(bookId);
if (book is not null)
{
await dynamics.RunContinuityChecksAsync(book.ProjectID, bookId, null);
await warnings.FinaliseSceneDependencyWarningsAsync(book.ProjectID, bookId);
}
return summary;
@ -1650,22 +1664,40 @@ public sealed class ContinuityValidationService(
public async Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId)
{
var summary = await warnings.ValidateSceneAsync(sceneId);
var scene = await scenes.GetAsync(sceneId);
if (scene is not null)
{
var chapter = await chapters.GetAsync(scene.ChapterID);
if (chapter is not null)
{
var book = await books.GetAsync(chapter.BookID);
if (book is not null)
{
await dynamics.RunContinuityChecksAsync(book.ProjectID, book.BookID, sceneId);
}
}
}
return summary;
}
public Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId) =>
bookId.HasValue ? warnings.ValidateBookAsync(bookId.Value) : warnings.ValidateProjectAsync(projectId);
public async Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId)
{
var summary = bookId.HasValue ? await warnings.ValidateBookAsync(bookId.Value) : await warnings.ValidateProjectAsync(projectId);
await dynamics.RunContinuityChecksAsync(projectId, bookId, null);
await warnings.FinaliseSceneDependencyWarningsAsync(projectId, bookId);
return summary;
}
public async Task<ContinuityValidationSummary> ValidateAssetAsync(int assetId)
{
var asset = await assets.GetAssetAsync(assetId);
return asset is null ? new ContinuityValidationSummary() : await warnings.ValidateProjectAsync(asset.ProjectID);
return asset is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(asset.ProjectID);
}
public async Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
return character is null ? new ContinuityValidationSummary() : await warnings.ValidateProjectAsync(character.ProjectID);
return character is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(character.ProjectID);
}
public Task<IReadOnlyList<ContinuityWarning>> ListSceneWarningsAsync(int sceneId) => warnings.ListBySceneAsync(sceneId);
@ -2176,6 +2208,85 @@ public sealed class ExportService(IExportRepository exports) : IExportService
}
}
public sealed class DynamicsService(IProjectRepository projects, IDynamicsRepository dynamics) : IDynamicsService
{
public async Task<CharacterArcViewModel?> GetCharacterArcAsync(int characterId)
{
var data = await dynamics.GetCharacterArcAsync(characterId);
if (data.Character is null)
{
return null;
}
var project = await projects.GetAsync(data.Character.ProjectID);
return project is null ? null : new CharacterArcViewModel
{
Project = project,
Character = data.Character,
ArcPoints = data.ArcPoints,
RelationshipEvents = data.RelationshipEvents,
KnowledgeEvents = data.KnowledgeEvents,
Issues = data.Issues
};
}
public async Task<RelationshipEvolutionViewModel?> GetRelationshipEvolutionAsync(int characterRelationshipId)
{
var data = await dynamics.GetRelationshipEvolutionAsync(characterRelationshipId);
if (data.Relationship is null)
{
return null;
}
var project = await projects.GetAsync(data.Relationship.ProjectID);
return project is null ? null : new RelationshipEvolutionViewModel
{
Project = project,
Relationship = data.Relationship,
Events = data.Events,
Issues = data.Issues
};
}
public async Task<KnowledgeTimelineViewModel?> GetKnowledgeTimelineAsync(int characterId)
{
var data = await dynamics.GetKnowledgeTimelineAsync(characterId);
if (data.Character is null)
{
return null;
}
var project = await projects.GetAsync(data.Character.ProjectID);
return project is null ? null : new KnowledgeTimelineViewModel
{
Project = project,
Character = data.Character,
KnowledgeEvents = data.KnowledgeEvents,
Issues = data.Issues
};
}
public async Task<StoryDynamicsViewModel?> GetStoryDynamicsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var data = await dynamics.GetStoryDynamicsAsync(projectId);
return new StoryDynamicsViewModel
{
Project = project,
Pairings = data.Pairings,
Issues = data.Issues
};
}
public Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId) =>
dynamics.RunContinuityChecksAsync(projectId, bookId, sceneId);
}
public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService
{
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)

View File

@ -0,0 +1,322 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
MERGE dbo.WarningTypes AS target
USING (VALUES
(N'Knowledge Continuity', N'Character may act on information before a recorded knowledge event.', 220),
(N'Abrupt Relationship Change', N'Relationship intensity changes sharply between nearby scenes.', 230),
(N'Long Character Absence', N'Character disappears for a long stretch of narrative scenes.', 240),
(N'Character Underused', N'Character appears lightly compared with their story connections.', 250),
(N'Scene Character Overload', N'Scene contains a large number of character appearances.', 260)
) AS source (TypeName, Description, SortOrder)
ON target.TypeName = source.TypeName
WHEN MATCHED THEN UPDATE SET Description = source.Description, SortOrder = source.SortOrder, IsActive = 1
WHEN NOT MATCHED THEN INSERT (TypeName, Description, SortOrder, IsActive) VALUES (source.TypeName, source.Description, source.SortOrder, 1);
GO
CREATE OR ALTER PROCEDURE dbo.Dynamics_CharacterArc
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int = (SELECT ProjectID FROM dbo.Characters WHERE CharacterID = @CharacterID);
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE CharacterID = @CharacterID;
SELECT sc.SceneID, nso.BookTitle, nso.ChapterNumber, nso.SceneNumber, nso.SceneTitle, nso.GlobalSceneIndex,
CAST(CASE WHEN COALESCE(s.POVCharacterID, c.POVCharacterID) = @CharacterID THEN 1 ELSE 0 END AS bit) AS IsPov,
MAX(CASE WHEN smt.MetricName = N'Emotional Weight' THEN smv.Value END) AS EmotionalWeight,
MAX(CASE WHEN smt.MetricName = N'Tension' THEN smv.Value END) AS Tension,
MAX(CASE WHEN smt.MetricName = N'Romance' THEN smv.Value END) AS Romance,
MAX(CASE WHEN smt.MetricName = N'Action' THEN smv.Value END) AS Action,
MAX(CASE WHEN smt.MetricName = N'Darkness' THEN smv.Value END) AS Darkness,
MAX(CASE WHEN smt.MetricName = N'Hope / Lightness' THEN smv.Value END) AS HopeLightness,
MAX(CASE WHEN smt.MetricName = N'Overall Intensity' THEN smv.Value END) AS OverallIntensity,
role.TypeName AS RoleInSceneTypeName,
presence.TypeName AS PresenceTypeName,
sc.EmotionalState,
sc.AppearanceNotes,
notes.KeyNotes
FROM dbo.SceneCharacters sc
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = sc.SceneID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
LEFT JOIN dbo.CharacterRoleInSceneTypes role ON role.CharacterRoleInSceneTypeID = sc.RoleInSceneTypeID
LEFT JOIN dbo.PresenceTypes presence ON presence.PresenceTypeID = sc.PresenceTypeID
LEFT JOIN dbo.SceneMetricValues smv ON smv.SceneID = sc.SceneID
LEFT JOIN dbo.SceneMetricTypes smt ON smt.MetricTypeID = smv.MetricTypeID
OUTER APPLY
(
SELECT STRING_AGG(CONVERT(nvarchar(max), COALESCE(sn.NoteTitle, sn.NoteText)), N' | ') AS KeyNotes
FROM dbo.SceneNotes sn
INNER JOIN dbo.SceneNoteTypes snt ON snt.SceneNoteTypeID = sn.SceneNoteTypeID
WHERE sn.SceneID = sc.SceneID AND sn.IsResolved = 0
AND snt.TypeName IN (N'Emotional Intent', N'Character Reminder', N'Revision Note', N'TODO')
) notes
WHERE sc.CharacterID = @CharacterID
GROUP BY sc.SceneID, nso.BookTitle, nso.ChapterNumber, nso.SceneNumber, nso.SceneTitle, nso.GlobalSceneIndex,
s.POVCharacterID, c.POVCharacterID, role.TypeName, presence.TypeName, sc.EmotionalState, sc.AppearanceNotes, notes.KeyNotes
ORDER BY nso.GlobalSceneIndex;
EXEC dbo.RelationshipEvent_ListByCharacter @CharacterID;
EXEC dbo.CharacterKnowledge_ListByCharacter @CharacterID;
SELECT N'Character Presence' AS Category, ws.SeverityName, cw.Message AS Title, cw.Details AS Detail, cw.SceneID, cw.EntityID, cw.EntityType
FROM dbo.ContinuityWarnings cw
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
WHERE cw.ProjectID = @ProjectID AND cw.IsDismissed = 0
AND (
(cw.EntityType = N'Character' AND cw.EntityID = @CharacterID)
OR cw.SceneID IN (SELECT SceneID FROM dbo.SceneCharacters WHERE CharacterID = @CharacterID)
)
AND wt.TypeName IN (N'Knowledge Continuity', N'Long Character Absence', N'Character Underused', N'Scene Character Overload');
END;
GO
CREATE OR ALTER PROCEDURE dbo.Dynamics_RelationshipEvolution
@CharacterRelationshipID int
AS
BEGIN
SET NOCOUNT ON;
SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName,
cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes, cr.CreatedDate, cr.UpdatedDate, cr.IsArchived
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
WHERE cr.CharacterRelationshipID = @CharacterRelationshipID;
SELECT re.RelationshipEventID, re.CharacterRelationshipID, re.SceneID, nso.BookTitle, nso.ChapterNumber, nso.SceneNumber,
nso.SceneTitle, nso.GlobalSceneIndex, rs.StateName AS RelationshipStateName, re.Intensity, re.IsKnownToOtherCharacter, re.Description
FROM dbo.RelationshipEvents re
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = re.SceneID
WHERE re.CharacterRelationshipID = @CharacterRelationshipID
ORDER BY nso.GlobalSceneIndex, re.RelationshipEventID;
SELECT N'Relationship Continuity' AS Category, ws.SeverityName, cw.Message AS Title, cw.Details AS Detail, cw.SceneID, cw.EntityID, cw.EntityType
FROM dbo.ContinuityWarnings cw
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
WHERE cw.IsDismissed = 0 AND cw.EntityType = N'Relationship' AND cw.EntityID = @CharacterRelationshipID
AND wt.TypeName = N'Abrupt Relationship Change';
END;
GO
CREATE OR ALTER PROCEDURE dbo.Dynamics_KnowledgeTimeline
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int = (SELECT ProjectID FROM dbo.Characters WHERE CharacterID = @CharacterID);
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE CharacterID = @CharacterID;
EXEC dbo.CharacterKnowledge_ListByCharacter @CharacterID;
SELECT N'Knowledge Continuity' AS Category, ws.SeverityName, cw.Message AS Title, cw.Details AS Detail, cw.SceneID, cw.EntityID, cw.EntityType
FROM dbo.ContinuityWarnings cw
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
WHERE cw.ProjectID = @ProjectID AND cw.IsDismissed = 0
AND cw.EntityType = N'Character' AND cw.EntityID = @CharacterID
AND wt.TypeName = N'Knowledge Continuity';
END;
GO
CREATE OR ALTER PROCEDURE dbo.Dynamics_StoryDashboard
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
;WITH Pairings AS
(
SELECT a.CharacterID AS CharacterAID, ca.CharacterName AS CharacterAName,
b.CharacterID AS CharacterBID, cb.CharacterName AS CharacterBName,
a.SceneID, nso.GlobalSceneIndex,
CONCAT(nso.BookTitle, N' / Ch ', nso.ChapterNumber, N' / Scene ', nso.SceneNumber, N': ', nso.SceneTitle) AS SceneLabel
FROM dbo.SceneCharacters a
INNER JOIN dbo.SceneCharacters b ON b.SceneID = a.SceneID AND b.CharacterID > a.CharacterID
INNER JOIN dbo.Characters ca ON ca.CharacterID = a.CharacterID
INNER JOIN dbo.Characters cb ON cb.CharacterID = b.CharacterID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = a.SceneID
WHERE ca.ProjectID = @ProjectID AND cb.ProjectID = @ProjectID AND ca.IsArchived = 0 AND cb.IsArchived = 0
),
Ranked AS
(
SELECT CharacterAID, CharacterAName, CharacterBID, CharacterBName, COUNT(*) AS SharedSceneCount,
MIN(GlobalSceneIndex) AS FirstIndex, MAX(GlobalSceneIndex) AS LastIndex
FROM Pairings
GROUP BY CharacterAID, CharacterAName, CharacterBID, CharacterBName
)
SELECT TOP (40) r.CharacterAID, r.CharacterAName, r.CharacterBID, r.CharacterBName, r.SharedSceneCount,
firstScene.SceneLabel AS FirstSceneLabel,
lastScene.SceneLabel AS LastSceneLabel
FROM Ranked r
OUTER APPLY (SELECT TOP (1) SceneLabel FROM Pairings p WHERE p.CharacterAID = r.CharacterAID AND p.CharacterBID = r.CharacterBID ORDER BY p.GlobalSceneIndex) firstScene
OUTER APPLY (SELECT TOP (1) SceneLabel FROM Pairings p WHERE p.CharacterAID = r.CharacterAID AND p.CharacterBID = r.CharacterBID ORDER BY p.GlobalSceneIndex DESC) lastScene
ORDER BY r.SharedSceneCount DESC, r.CharacterAName, r.CharacterBName;
SELECT Category, SeverityName, Title, Detail, SceneID, EntityID, EntityType
FROM
(
SELECT N'Continuity' AS Category, ws.SeverityName, cw.Message AS Title, cw.Details AS Detail, cw.SceneID, cw.EntityID, cw.EntityType
FROM dbo.ContinuityWarnings cw
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
WHERE cw.ProjectID = @ProjectID AND cw.IsDismissed = 0
AND wt.TypeName IN (N'Knowledge Continuity', N'Abrupt Relationship Change', N'Long Character Absence', N'Character Underused', N'Scene Character Overload')
) issues
ORDER BY SeverityName DESC, Title;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Dynamics_RunContinuityChecks
@ProjectID int,
@BookID int = NULL,
@SceneID int = NULL
AS
BEGIN
SET NOCOUNT ON;
;WITH RelationshipSteps AS
(
SELECT cr.CharacterRelationshipID, cr.ProjectID, nso.BookID, nso.ChapterID, re.SceneID,
ca.CharacterName AS CharacterAName, cb.CharacterName AS CharacterBName, re.Intensity,
LAG(re.Intensity) OVER (PARTITION BY cr.CharacterRelationshipID ORDER BY nso.GlobalSceneIndex, re.RelationshipEventID) AS PreviousIntensity,
LAG(nso.SceneTitle) OVER (PARTITION BY cr.CharacterRelationshipID ORDER BY nso.GlobalSceneIndex, re.RelationshipEventID) AS PreviousSceneTitle
FROM dbo.RelationshipEvents re
INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID AND cr.IsArchived = 0
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = re.SceneID
WHERE cr.ProjectID = @ProjectID
)
INSERT dbo.ContinuityWarnings (ProjectID, BookID, ChapterID, SceneID, EntityType, EntityID, WarningTypeID, WarningSeverityID, Message, Details)
SELECT rs.ProjectID, rs.BookID, rs.ChapterID, rs.SceneID, N'Relationship', rs.CharacterRelationshipID,
wt.WarningTypeID, ws.WarningSeverityID,
N'Possible emotional transition issue. Relationship intensity changes sharply between scenes.',
CONCAT(rs.CharacterAName, N' / ', rs.CharacterBName, N' changes from ', rs.PreviousIntensity, N' to ', rs.Intensity, N'. Previous scene: ', rs.PreviousSceneTitle, N'.')
FROM RelationshipSteps rs
CROSS JOIN dbo.WarningTypes wt
CROSS JOIN dbo.WarningSeverities ws
WHERE wt.TypeName = N'Abrupt Relationship Change' AND ws.SeverityName = N'Info'
AND rs.Intensity IS NOT NULL AND rs.PreviousIntensity IS NOT NULL AND ABS(rs.Intensity - rs.PreviousIntensity) >= 5
AND (@BookID IS NULL OR rs.BookID = @BookID) AND (@SceneID IS NULL OR rs.SceneID = @SceneID)
AND NOT EXISTS (SELECT 1 FROM dbo.ContinuityWarnings existing WHERE existing.ProjectID = rs.ProjectID AND existing.SceneID = rs.SceneID AND existing.EntityType = N'Relationship' AND existing.EntityID = rs.CharacterRelationshipID AND existing.WarningTypeID = wt.WarningTypeID AND existing.IsDismissed = 0 AND existing.IsIntentional = 0);
;WITH Appearances AS
(
SELECT sc.CharacterID, ch.CharacterName, nso.ProjectID, nso.BookID, nso.ChapterID, sc.SceneID, nso.GlobalSceneIndex,
LAG(nso.GlobalSceneIndex) OVER (PARTITION BY sc.CharacterID ORDER BY nso.GlobalSceneIndex) AS PreviousIndex
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = sc.SceneID
WHERE ch.ProjectID = @ProjectID AND ch.IsArchived = 0
)
INSERT dbo.ContinuityWarnings (ProjectID, BookID, ChapterID, SceneID, EntityType, EntityID, WarningTypeID, WarningSeverityID, Message, Details)
SELECT ProjectID, BookID, ChapterID, SceneID, N'Character', CharacterID, wt.WarningTypeID, ws.WarningSeverityID,
CONCAT(N'Possible review: ', CharacterName, N' is absent for ', GlobalSceneIndex - PreviousIndex - 1, N' narrative scenes.'),
N'Long absences are sometimes useful, but this may be worth checking after a major beat.'
FROM Appearances
CROSS JOIN dbo.WarningTypes wt
CROSS JOIN dbo.WarningSeverities ws
WHERE wt.TypeName = N'Long Character Absence' AND ws.SeverityName = N'Info'
AND PreviousIndex IS NOT NULL AND GlobalSceneIndex - PreviousIndex - 1 >= 12
AND (@BookID IS NULL OR BookID = @BookID) AND (@SceneID IS NULL OR SceneID = @SceneID)
AND NOT EXISTS (SELECT 1 FROM dbo.ContinuityWarnings existing WHERE existing.ProjectID = Appearances.ProjectID AND existing.SceneID = Appearances.SceneID AND existing.EntityType = N'Character' AND existing.EntityID = Appearances.CharacterID AND existing.WarningTypeID = wt.WarningTypeID AND existing.IsDismissed = 0 AND existing.IsIntentional = 0);
;WITH CharacterUsage AS
(
SELECT ch.ProjectID, ch.CharacterID, ch.CharacterName,
COUNT(DISTINCT sc.SceneID) AS AppearanceCount,
COUNT(DISTINCT cr.CharacterRelationshipID) AS RelationshipCount
FROM dbo.Characters ch
LEFT JOIN dbo.SceneCharacters sc ON sc.CharacterID = ch.CharacterID
LEFT JOIN dbo.CharacterRelationships cr ON cr.ProjectID = ch.ProjectID
AND cr.IsArchived = 0
AND ch.CharacterID IN (cr.CharacterAID, cr.CharacterBID)
WHERE ch.ProjectID = @ProjectID AND ch.IsArchived = 0 AND @SceneID IS NULL
GROUP BY ch.ProjectID, ch.CharacterID, ch.CharacterName
HAVING COUNT(DISTINCT cr.CharacterRelationshipID) >= 2 AND COUNT(DISTINCT sc.SceneID) <= 1
)
INSERT dbo.ContinuityWarnings (ProjectID, EntityType, EntityID, WarningTypeID, WarningSeverityID, Message, Details)
SELECT ProjectID, N'Character', CharacterID, wt.WarningTypeID, ws.WarningSeverityID,
CONCAT(N'Possible review: ', CharacterName, N' has several relationship links but very few scene appearances.'),
N'This may be intentional, but the character could be underused relative to their story connections.'
FROM CharacterUsage
CROSS JOIN dbo.WarningTypes wt
CROSS JOIN dbo.WarningSeverities ws
WHERE wt.TypeName = N'Character Underused' AND ws.SeverityName = N'Info'
AND NOT EXISTS (SELECT 1 FROM dbo.ContinuityWarnings existing WHERE existing.ProjectID = CharacterUsage.ProjectID AND existing.EntityType = N'Character' AND existing.EntityID = CharacterUsage.CharacterID AND existing.WarningTypeID = wt.WarningTypeID AND existing.IsDismissed = 0 AND existing.IsIntentional = 0);
;WITH SceneCounts AS
(
SELECT nso.ProjectID, nso.BookID, nso.ChapterID, nso.SceneID, nso.SceneTitle, COUNT(*) AS CharacterCount
FROM dbo.SceneCharacters sc
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = sc.SceneID
WHERE nso.ProjectID = @ProjectID AND (@BookID IS NULL OR nso.BookID = @BookID) AND (@SceneID IS NULL OR nso.SceneID = @SceneID)
GROUP BY nso.ProjectID, nso.BookID, nso.ChapterID, nso.SceneID, nso.SceneTitle
HAVING COUNT(*) >= 7
)
INSERT dbo.ContinuityWarnings (ProjectID, BookID, ChapterID, SceneID, EntityType, EntityID, WarningTypeID, WarningSeverityID, Message, Details)
SELECT ProjectID, BookID, ChapterID, SceneID, N'Scene', SceneID, wt.WarningTypeID, ws.WarningSeverityID,
CONCAT(N'Possible review: this scene has ', CharacterCount, N' character appearances.'),
N'Busy scenes can work well, but may need extra clarity around focus and blocking.'
FROM SceneCounts
CROSS JOIN dbo.WarningTypes wt
CROSS JOIN dbo.WarningSeverities ws
WHERE wt.TypeName = N'Scene Character Overload' AND ws.SeverityName = N'Info'
AND NOT EXISTS (SELECT 1 FROM dbo.ContinuityWarnings existing WHERE existing.ProjectID = SceneCounts.ProjectID AND existing.SceneID = SceneCounts.SceneID AND existing.EntityType = N'Scene' AND existing.EntityID = SceneCounts.SceneID AND existing.WarningTypeID = wt.WarningTypeID AND existing.IsDismissed = 0 AND existing.IsIntentional = 0);
;WITH Knowledge AS
(
SELECT ck.CharacterID, ch.CharacterName, ck.StoryAssetID, ck.PlotThreadID, ck.SceneID, nso.ProjectID, nso.BookID, nso.ChapterID, nso.GlobalSceneIndex,
MIN(nso.GlobalSceneIndex) OVER (PARTITION BY ck.CharacterID, ISNULL(ck.StoryAssetID, 0), ISNULL(ck.PlotThreadID, 0)) AS FirstKnownIndex
FROM dbo.CharacterKnowledge ck
INNER JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = ck.SceneID
WHERE ch.ProjectID = @ProjectID
),
Candidate AS
(
SELECT DISTINCT sc.CharacterID, ch.CharacterName, te.PlotThreadID, ae.StoryAssetID, nso.ProjectID, nso.BookID, nso.ChapterID, sc.SceneID, nso.GlobalSceneIndex
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID
INNER JOIN dbo.NarrativeSceneOrder nso ON nso.SceneID = sc.SceneID
LEFT JOIN dbo.ThreadEvents te ON te.SceneID = sc.SceneID
LEFT JOIN dbo.AssetEvents ae ON ae.SceneID = sc.SceneID
WHERE ch.ProjectID = @ProjectID AND (@BookID IS NULL OR nso.BookID = @BookID) AND (@SceneID IS NULL OR sc.SceneID = @SceneID)
AND (te.PlotThreadID IS NOT NULL OR ae.StoryAssetID IS NOT NULL)
)
INSERT dbo.ContinuityWarnings (ProjectID, BookID, ChapterID, SceneID, EntityType, EntityID, WarningTypeID, WarningSeverityID, Message, Details)
SELECT c.ProjectID, c.BookID, c.ChapterID, c.SceneID, N'Character', c.CharacterID, wt.WarningTypeID, ws.WarningSeverityID,
CONCAT(N'Possible review: ', c.CharacterName, N' appears in a scene involving information without an earlier knowledge event.'),
N'This is advisory only. Add a knowledge event if the character is meant to know this, or mark the warning intentional.'
FROM Candidate c
CROSS JOIN dbo.WarningTypes wt
CROSS JOIN dbo.WarningSeverities ws
WHERE wt.TypeName = N'Knowledge Continuity' AND ws.SeverityName = N'Info'
AND NOT EXISTS
(
SELECT 1 FROM Knowledge k
WHERE k.CharacterID = c.CharacterID
AND ((c.StoryAssetID IS NOT NULL AND k.StoryAssetID = c.StoryAssetID) OR (c.PlotThreadID IS NOT NULL AND k.PlotThreadID = c.PlotThreadID))
AND k.FirstKnownIndex <= c.GlobalSceneIndex
)
AND NOT EXISTS (SELECT 1 FROM dbo.ContinuityWarnings existing WHERE existing.ProjectID = c.ProjectID AND existing.SceneID = c.SceneID AND existing.EntityType = N'Character' AND existing.EntityID = c.CharacterID AND existing.WarningTypeID = wt.WarningTypeID AND existing.IsDismissed = 0 AND existing.IsIntentional = 0);
END;
GO

View File

@ -284,6 +284,39 @@ public sealed class WriterDashboardViewModel
public IReadOnlyList<StoryBibleAttentionItem> StoryHealthReminders { get; set; } = [];
}
public sealed class CharacterArcViewModel
{
public Project Project { get; set; } = new();
public Character Character { get; set; } = new();
public IReadOnlyList<CharacterArcPoint> ArcPoints { get; set; } = [];
public IReadOnlyList<RelationshipEvent> RelationshipEvents { get; set; } = [];
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents { get; set; } = [];
public IReadOnlyList<DynamicsIssue> Issues { get; set; } = [];
}
public sealed class RelationshipEvolutionViewModel
{
public Project Project { get; set; } = new();
public CharacterRelationship Relationship { get; set; } = new();
public IReadOnlyList<RelationshipProgressionPoint> Events { get; set; } = [];
public IReadOnlyList<DynamicsIssue> Issues { get; set; } = [];
}
public sealed class KnowledgeTimelineViewModel
{
public Project Project { get; set; } = new();
public Character Character { get; set; } = new();
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeEvents { get; set; } = [];
public IReadOnlyList<DynamicsIssue> Issues { get; set; } = [];
}
public sealed class StoryDynamicsViewModel
{
public Project Project { get; set; } = new();
public IReadOnlyList<CharacterPairingSummary> Pairings { get; set; } = [];
public IReadOnlyList<DynamicsIssue> Issues { get; set; } = [];
}
public sealed class TimelineViewModel
{
public Project Project { get; set; } = new();

View File

@ -40,6 +40,7 @@
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Project</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Timeline</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Warnings</a>
<a asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
<a asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>

View File

@ -33,6 +33,8 @@
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Back to timeline</a>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Character arc</a>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge timeline</a>
<a class="btn btn-outline-primary" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID" asp-route-format="markdown">Markdown</a>
<a class="btn btn-outline-primary" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
@ -62,6 +64,7 @@
<div class="asset-mini-item">
<strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong>
<span>@relationship.RelationshipTypeName</span>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a>
</div>
}
</div>

View File

@ -0,0 +1,136 @@
@model CharacterArcViewModel
@{
ViewData["Title"] = $"{Model.Character.CharacterName} Arc";
var arcScenes = Model.ArcPoints
.OrderBy(point => point.GlobalSceneIndex)
.Select(point => new
{
sceneId = point.SceneID,
axisLabel = $"Ch {point.ChapterNumber:g}.{point.SceneNumber:g}",
chapterLabel = $"Chapter {point.ChapterNumber:g}",
sceneNumber = point.SceneNumber.ToString("g"),
sceneTitle = point.SceneTitle,
bookTitle = point.BookTitle,
characterAppears = true,
characterPresence = point.IsPov ? "POV scene" : point.PresenceTypeName ?? "Appears",
timelineUrl = Url.Action("Edit", "Scenes", new { id = point.SceneID })
})
.ToList();
var relationshipIntensityPoints = Model.RelationshipEvents
.Where(item => item.Intensity.HasValue)
.GroupBy(item => item.SceneID)
.Select(group => new { sceneId = group.Key, value = Convert.ToInt32(Math.Round(group.Average(item => item.Intensity!.Value))) });
var arcGraphMetrics = new[]
{
new { metricName = "Overall Intensity", points = Model.ArcPoints.Where(point => point.OverallIntensity.HasValue).Select(point => new { sceneId = point.SceneID, value = point.OverallIntensity!.Value }) },
new { metricName = "Emotional Weight", points = Model.ArcPoints.Where(point => point.EmotionalWeight.HasValue).Select(point => new { sceneId = point.SceneID, value = point.EmotionalWeight!.Value }) },
new { metricName = "Tension", points = Model.ArcPoints.Where(point => point.Tension.HasValue).Select(point => new { sceneId = point.SceneID, value = point.Tension!.Value }) },
new { metricName = "Romance", points = Model.ArcPoints.Where(point => point.Romance.HasValue).Select(point => new { sceneId = point.SceneID, value = point.Romance!.Value }) },
new { metricName = "Action", points = Model.ArcPoints.Where(point => point.Action.HasValue).Select(point => new { sceneId = point.SceneID, value = point.Action!.Value }) },
new { metricName = "Darkness", points = Model.ArcPoints.Where(point => point.Darkness.HasValue).Select(point => new { sceneId = point.SceneID, value = point.Darkness!.Value }) },
new { metricName = "Hope / Lightness", points = Model.ArcPoints.Where(point => point.HopeLightness.HasValue).Select(point => new { sceneId = point.SceneID, value = point.HopeLightness!.Value }) },
new { metricName = "Relationship Intensity", points = relationshipIntensityPoints }
}
.Where(metric => metric.points.Any())
.ToList();
var arcGraphPayload = new
{
defaultMetrics = new[] { "Overall Intensity", "Emotional Weight", "Tension" },
scenes = arcScenes,
metrics = arcGraphMetrics
};
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<a asp-controller="Characters" asp-action="Details" asp-route-id="@Model.Character.CharacterID">@Model.Character.CharacterName</a>
<span>Character arc</span>
</nav>
<div class="page-heading">
<div>
<p class="eyebrow">Character Arc</p>
<h1>@Model.Character.CharacterName</h1>
<p class="lead-text">Appearances, emotional shape, relationship changes, knowledge gains, and advisory notes.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Show on timeline</a>
</div>
</div>
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Arc Shape</p>
<h2>Character Metric Shape</h2>
</div>
<span class="soft-count">@arcGraphMetrics.Count metric@(arcGraphMetrics.Count == 1 ? "" : "s")</span>
</div>
@if (!arcGraphMetrics.Any() || !arcScenes.Any())
{
<p class="muted">Add scene metrics to scenes where @Model.Character.CharacterName appears to see their emotional and story shape.</p>
}
else
{
<div class="metric-shape-chart character-arc-chart" data-metric-shape-chart>
<p class="metric-shape-intro">Compare this character's scene metrics across appearances in narrative order. Click a point to open the scene.</p>
<div class="metric-toggle-row" data-metric-toggles></div>
<div class="metric-chart-frame">
<canvas aria-label="Comparative character arc line graph"></canvas>
<p class="muted d-none" data-chart-fallback>Character arc chart could not load. The scene markers are still listed below.</p>
</div>
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(arcGraphPayload))</script>
</div>
}
</section>
<section class="story-bible-section">
<div class="story-bible-grid">
<article class="story-bible-card">
<h3>Appearance Frequency</h3>
<p class="writer-kpi">@Model.ArcPoints.Count</p>
<p class="muted">@Model.ArcPoints.Count(x => x.IsPov) POV scene@(Model.ArcPoints.Count(x => x.IsPov) == 1 ? "" : "s")</p>
</article>
<article class="story-bible-card">
<h3>Knowledge Gains</h3>
<p class="writer-kpi">@Model.KnowledgeEvents.Count</p>
</article>
<article class="story-bible-card">
<h3>Relationship Beats</h3>
<p class="writer-kpi">@Model.RelationshipEvents.Count</p>
</article>
</div>
</section>
<partial name="_DynamicsIssues" model="Model.Issues" />
<section class="story-bible-section">
<h2>Scene Markers</h2>
<div class="writer-queue-list">
@foreach (var point in Model.ArcPoints)
{
<article class="writer-queue-card">
<div class="writer-queue-title">
<h3>@point.BookTitle / Ch @point.ChapterNumber / Scene @point.SceneNumber: @point.SceneTitle</h3>
@if (point.IsPov) { <span class="status-pill">POV</span> }
</div>
<div class="story-bible-meta">
<span>Emotional @DisplayValue(point.EmotionalWeight)</span>
<span>Tension @DisplayValue(point.Tension)</span>
<span>Romance @DisplayValue(point.Romance)</span>
<span>Action @DisplayValue(point.Action)</span>
<span>@(point.RoleInSceneTypeName ?? "No role")</span>
</div>
@if (!string.IsNullOrWhiteSpace(point.EmotionalState) || !string.IsNullOrWhiteSpace(point.KeyNotes))
{
<p>@point.EmotionalState @point.KeyNotes</p>
}
</article>
}
</div>
</section>
@functions {
private static string DisplayValue(int? value) => value?.ToString() ?? "-";
}

View File

@ -0,0 +1,30 @@
@model KnowledgeTimelineViewModel
@{
ViewData["Title"] = $"{Model.Character.CharacterName} Knowledge";
}
<div class="page-heading">
<div>
<p class="eyebrow">Knowledge Timeline</p>
<h1>@Model.Character.CharacterName</h1>
<p class="lead-text">What this character knows, suspects, misunderstands, or learns across the story.</p>
</div>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Character arc</a>
</div>
<partial name="_DynamicsIssues" model="Model.Issues" />
<section class="story-bible-section">
<div class="writer-queue-list">
@foreach (var item in Model.KnowledgeEvents)
{
<article class="writer-queue-card">
<div class="writer-queue-title">
<h3>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</h3>
<span class="status-pill">@item.KnowledgeStateName</span>
</div>
<p><strong>@(item.AssetName ?? item.ThreadTitle ?? "Knowledge")</strong> @item.Description</p>
</article>
}
</div>
</section>

View File

@ -0,0 +1,52 @@
@model RelationshipEvolutionViewModel
@{
ViewData["Title"] = "Relationship Evolution";
var maxIndex = Math.Max(Model.Events.Any() ? Model.Events.Max(x => x.GlobalSceneIndex) : 1, 1);
}
<div class="page-heading">
<div>
<p class="eyebrow">Relationship Evolution</p>
<h1>@Model.Relationship.CharacterAName -> @Model.Relationship.CharacterBName</h1>
<p class="lead-text">@Model.Relationship.RelationshipTypeName</p>
</div>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-FocusType="relationship" asp-route-FocusID="@Model.Relationship.CharacterRelationshipID">Show on timeline</a>
</div>
<section class="story-bible-section">
<div class="dynamics-timeline relationship">
@foreach (var item in Model.Events)
{
var left = (item.GlobalSceneIndex - 1) * 100m / maxIndex;
<a class="dynamics-marker" style="left:@left%" title="@item.RelationshipStateName @item.Intensity" asp-controller="Scenes" asp-action="Edit" asp-route-id="@item.SceneID">
<span style="height:@Math.Max(item.Intensity ?? 3, 1)0%"></span>
</a>
}
</div>
</section>
<partial name="_DynamicsIssues" model="Model.Issues" />
<section class="story-bible-section">
<h2>Relationship History</h2>
<div class="writer-queue-list">
@foreach (var item in Model.Events)
{
<article class="writer-queue-card">
<div class="writer-queue-title">
<h3>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</h3>
<span class="status-pill">@item.RelationshipStateName</span>
</div>
<div class="story-bible-meta">
<span>Intensity @DisplayValue(item.Intensity)</span>
<span>@(item.IsKnownToOtherCharacter ? "Known to other character" : "Private/uncertain")</span>
</div>
<p>@item.Description</p>
</article>
}
</div>
</section>
@functions {
private static string DisplayValue(int? value) => value?.ToString() ?? "-";
}

View File

@ -0,0 +1,41 @@
@model StoryDynamicsViewModel
@{
ViewData["Title"] = "Story Dynamics";
}
<div class="page-heading">
<div>
<p class="eyebrow">Story Dynamics</p>
<h1>@Model.Project.ProjectName</h1>
<p class="lead-text">Character pairings, neglected arcs, abrupt transitions, knowledge jumps, and advisory continuity signals.</p>
</div>
<form asp-action="RunChecks" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-primary" type="submit">Run dynamics checks</button>
</form>
</div>
<partial name="_DynamicsIssues" model="Model.Issues" />
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Pairing Heatmap</p>
<h2>Character Pairings</h2>
</div>
<span class="soft-count">@Model.Pairings.Count pairings</span>
</div>
<div class="story-bible-grid story-bible-grid-compact">
@foreach (var pairing in Model.Pairings)
{
<article class="story-bible-card">
<div class="story-bible-card-title">
<h3>@pairing.CharacterAName + @pairing.CharacterBName</h3>
<span>@pairing.SharedSceneCount scenes</span>
</div>
<p class="muted">First: @(pairing.FirstSceneLabel ?? "Not placed")</p>
<p class="muted">Last: @(pairing.LastSceneLabel ?? "Not placed")</p>
</article>
}
</div>
</section>

View File

@ -0,0 +1,35 @@
@model IReadOnlyList<DynamicsIssue>
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Advisory checks</p>
<h2>Things to Review</h2>
</div>
<span class="soft-count">@Model.Count item@(Model.Count == 1 ? "" : "s")</span>
</div>
@if (!Model.Any())
{
<p class="muted">No dynamics issues found for this view.</p>
}
else
{
<div class="attention-list">
@foreach (var issue in Model)
{
<article>
<span class="badge text-bg-light">@issue.Category</span>
<strong>@issue.Title</strong>
@if (!string.IsNullOrWhiteSpace(issue.Detail))
{
<p>@issue.Detail</p>
}
@if (issue.SceneID.HasValue)
{
<a asp-controller="Scenes" asp-action="Edit" asp-route-id="@issue.SceneID">Open scene</a>
}
</article>
}
</div>
}
</section>

View File

@ -18,6 +18,7 @@
<a asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a>
<a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
@ -47,6 +48,7 @@
<a class="btn btn-outline-primary" asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
<a class="btn btn-outline-primary" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a class="btn btn-outline-primary" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a>
<a class="btn btn-primary" asp-controller="Books" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">Add book</a>
</div>
</div>

View File

@ -2746,6 +2746,54 @@ body.dragging-location [data-drag-type="location"] {
margin: -8px 0 18px;
}
.dynamics-timeline {
position: relative;
min-height: 128px;
border: 1px solid var(--plotline-line);
border-radius: 14px;
background: linear-gradient(180deg, rgba(47, 111, 99, 0.07), rgba(255,255,255,0.84));
overflow: hidden;
}
.dynamics-timeline::before {
content: "";
position: absolute;
left: 12px;
right: 12px;
top: 64px;
height: 1px;
background: rgba(36, 32, 26, 0.18);
}
.dynamics-marker {
position: absolute;
bottom: 18px;
width: 12px;
height: 86px;
transform: translateX(-50%);
display: flex;
align-items: end;
justify-content: center;
}
.dynamics-marker span {
display: block;
width: 8px;
min-height: 12px;
max-height: 82px;
border-radius: 999px;
background: var(--plotline-accent);
box-shadow: 0 8px 20px rgba(47, 111, 99, 0.22);
}
.dynamics-marker.pov span {
background: #8a4b2f;
}
.dynamics-timeline.relationship .dynamics-marker span {
background: #7c5fb0;
}
@media print {
header,
.breadcrumb-trail,

View File

@ -124,7 +124,7 @@
"#444b57"
];
const defaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]);
const globalDefaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]);
const chapterGuidePlugin = {
id: "plotlineChapterGuides",
@ -186,8 +186,8 @@
return;
}
const defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics);
const labels = payload.scenes.map((scene) => scene.axisLabel);
const sceneIndexById = new Map(payload.scenes.map((scene, index) => [scene.sceneId, index]));
const datasets = payload.metrics.map((metric, metricIndex) => {
const pointMap = new Map(metric.points.map((point) => [point.sceneId, point.value]));
const colour = colours[metricIndex % colours.length];
@ -259,7 +259,18 @@
},
afterBody(items) {
const scene = payload.scenes[items[0].dataIndex];
return scene.bookTitle ? scene.bookTitle : "";
const lines = [];
if (scene.characterPresence) {
lines.push(scene.characterPresence);
} else if (scene.characterAppears !== undefined) {
lines.push(scene.characterAppears ? "Character appears" : "Character does not appear");
}
if (scene.bookTitle) {
lines.push(scene.bookTitle);
}
return lines;
}
}
}