mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-07 06:18:03 +03:00
feat: a start on goals/quests
This commit is contained in:
@@ -18,57 +18,28 @@ properly is _just_ as important!
|
||||
|
||||
By using Tachi, you get access to powerful, novel rhythm game score-tracking features, like automatically breaking your scores into sessions, setting goals and rivals, and more!
|
||||
|
||||
There are way more features that Tachi has, and you can read about all of them [here](https://docs.tachi.ac/wiki/features).
|
||||
|
||||
## Setup
|
||||
|
||||
Tachi maintains a setup guide that is easy to follow even if you've never programmed before.
|
||||
|
||||
Check the [Documentation](https://docs.tachi.ac/contributing/setup) for how to set Tachi up.
|
||||
|
||||
You can then check the component-specific guides to see how to run those components and contribute back!
|
||||
|
||||
## Quick Setup For Experienced Programmers
|
||||
|
||||
- Open `Tachi` in vscode
|
||||
- Open `Tachi` in [VSCode](https://code.visualstudio.com) or [Cursor](https://cursor.com).
|
||||
- Other editors can be used, but you will need to set up devcontainers yourself.
|
||||
- Install the `Dev Containers` extension
|
||||
- Run `Dev Container: Rebuild and Open in Container`
|
||||
- (It can take a couple minutes to build and bootstrap)
|
||||
- You should be placed inside a vscode instance called `Tachi (dev-container)`
|
||||
- Inside there, you'll have a perfectly set up shell with fancy utils and fancy graphics. Use `just start` to start Tachi, use `seeds` to run seeds scripts, etc.
|
||||
|
||||
You should not be doing Tachi dev outside of the container. The container is set up perfectly with the right versions of everything and working ESLint and all that.
|
||||
|
||||
### Unsupported stuff
|
||||
|
||||
Tachi is intended to be developed inside a container. This ensures that you have the correct version of MongoDB, Redis, Typescript and all that jazz.
|
||||
|
||||
VSCode has excellent native support for dev containers, and as such this is the only method of local development we recommend.
|
||||
|
||||
Over the years we have had a _lot_ of issues with people having subtle variations on their system (or on windows). Given the contributor-centricity of Tachi, it's untenable to expect every contributor to be an expert with local dev setup.
|
||||
|
||||
The devcontainer provides us with the most simple, consistent experience, and allows us to put nice-to-haves inside the user's shell.
|
||||
|
||||
That said, if you're against using vscode you can:
|
||||
|
||||
- Start up the docker-compose file.
|
||||
- `docker exec` your way into the dev container to get a shell
|
||||
- Run `./dev/bootstrap.sh` inside the container.
|
||||
- Use this shell from now on to run Tachi commands! Edit the code in whatever editor you want.
|
||||
|
||||
If those instructions don't make sense to you, I really recommend just using dev containers.
|
||||
|
||||
Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (bun, postgres, redis, ts-node...) and `dev/bootstrap.sh`.
|
||||
**Doing Tachi dev outside of the devcontainer is unsupported.** The container is set up perfectly with the right versions of everything and is the expected way to do things.
|
||||
Please do not report bugs in the local setup if you are not using the devcontainer.
|
||||
|
||||
## Repository Info
|
||||
|
||||
This monorepo contains the following codebases:
|
||||
|
||||
- `client/`, Which is a React frontend for Tachi. (AGPL3)
|
||||
- `typescript/client/`, Which is a React frontend for Tachi. (AGPL3)
|
||||
|
||||
The client and the server are fairly decoupled. Someone could trivially create their own frontend client for Tachi.
|
||||
|
||||
- `server/`, Which is an Express-Typescript backend for Tachi. (AGPL3)
|
||||
- `typescript/server/`, Which is an Express-Typescript backend for Tachi. (AGPL3)
|
||||
|
||||
This contains all of our API calls, and interfaces with our database, and powers the actual score import engine.
|
||||
|
||||
@@ -77,12 +48,12 @@ This contains all of our API calls, and interfaces with our database, and powers
|
||||
**This is the source of truth for the songs, charts, and more on the site!**
|
||||
By submitting PRs to this, you can fix bugs on the website, add new charts, and more.
|
||||
|
||||
- `bot/`, Which is a discord bot frontend for Tachi. (MIT)
|
||||
- `typescript/bot/`, Which is a discord bot frontend for Tachi. (MIT)
|
||||
|
||||
- `common/`, Which contains common types, utils and functions shared between all other packages. (MIT)
|
||||
- `typescript/common/`, Which contains common types, utils and functions shared between all other packages. (MIT)
|
||||
|
||||
This is also published to NPM when it hits production.
|
||||
|
||||
- `docs/`, Which contains Tachi documentation. (MIT)
|
||||
|
||||
- `sieglinde/`, Which contains our BMS/PMS analysis functions. (MIT)
|
||||
- `typescript/sieglinde/`, Which contains our BMS/PMS analysis functions. (MIT)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"Gcea58fb099db74612d9654c4e8fec345154ce00c31d7525ea03efc7389a27c09": "G1a2499cae27572b8bddd861b28aaa01dbe4c885544f15fae5ed91765a7b924a9",
|
||||
"Gb0d9e72831b267ce5f1feaa84f99080df6a9b371ce3a22b6754baca3c8afffaf": "Gdee336867a22a0a789ab45b863775f4351b6f70fbaa0ea1928c977179320d0ae",
|
||||
"Ge3165eb0547fb894c169abc11e6f541b839ec8535bef72c895b64ca06b5767ae": "G36f76a980802c1f06060e29f0d4d590fa06cb2ab2d5059b62e72489b98e0cdfe",
|
||||
"Gccdd7b7f8f476397bf624791745252d71fe5eb276f832c8dc05384ae8a5c9c6b": "G53d4c238478106b7a54b865f02d505f650c29b9bda8ff7ce229389b2bafd575a",
|
||||
"G27018d6ed0720684cd03cb99104d1f1146e391f5e53ddd0d1d488b6e06722c1e": "G4a86b8c4a0e2c60bbf713d1be8f4ba7e4892ede6954a499beb8100f41ce78391",
|
||||
"G3ba44cf2fa1a53bb8de5cc99d5d4c5208ebd06250c2052393d7a4a830ab72f63": "G6da6680177acd81b493da1c8163174b6ac517c6463b25f0259f377bc9f2a3966",
|
||||
"G2ffadcc4aac205cc4af0c91e2c20dcb1a68564d591d7c802467e7122b70a7fda": "G749a8d3ac2bf391d65aee7491bd64bf0b20097ac4c097bc9dd0f42bccf5da047",
|
||||
"Ge536bd96adc49bb76c4168fe6121e8e0c046a9c82c8e22a72f9150ae9ca317b8": "G825995e16f75db2a31953a41564f3ceabdb250a3388843daafd527098989d78f",
|
||||
"G76994cf527cc8f78cc5bec3591439b980087e8e5a305e04e418d70b4b8556d61": "G5b99b92615fc9d1554e8bec4264885d642a5dd5ab9daba82cfc840da6261fcb0",
|
||||
"G9758f5f089bdc9757ad5991b1282f4ed39f5a197343f1fbd3d9f6a5a5985f8e3": "G39cde64528a80e9a7b22f6a612d5567945c9e1e7a6e6bdb7275ff39029d98a71",
|
||||
"G4fd0a9ec034100c7bbf40527b0bc55be71617c545f06d4c7feb4795fc5ed0601": "G2634ee3209ddc4d87edb90c34471ea8603bf22885ac53daae36979fcfe656182",
|
||||
"G018c3d8a77fb97bf5534f29ff960797f82effd1c8c3852da7ddf62170bf0fbba": "G1dafbb9a067708d0d52610a52d9e3d9efdd1ee8c40198f23a7bb219e78c5513b",
|
||||
"Ga13da591f9b95e132a7a1947603745649494154cec51d1dc427a9b83633edb64": "Gf460944fe4fa8d1cd8b1db041e9475b33e4d25f6dc49b383ea144d4e5180dd4e",
|
||||
"G4f7d560c4b930e1c689c8aa10c87fc8bb873231da207f7375dbcdac46b6ecf35": "G1f254c17d443890622d34cee5ea0e216184c9637f76e1266eb99e0540da9fdc6",
|
||||
"G226a21e1f284a93142fe1259067fceef84ba44eb1dc8d5fb02059f0c30d2b39b": "G8a7ec915d0613ce018202caa1e16dc9dbbf5ce18cb391110bc091e9136da6e00",
|
||||
"Gc3a2eeb0712812682aa514e0029cb4c1b2617f5c1ed51d80319f796f388ed29b": "Gcf8d759883e71996af9623499956d3d6ee6e591b7a58e6b8698469580ea2212d",
|
||||
"Gb36433d9e006d2371ce7cd337b798ec63afee190a796b5cd9c4e901c86c973df": "Gd467e4325e60010906055a976204bffd89a5fa805fbcf898c62618a8f81a57a3",
|
||||
"Ge0238448ca7e640b3c2330b4e8352eda8ce728de9b49f80b25fe5e69bd34a6f0": "G01d884675908fc9e06a7e5b477afd10bf932a06ccbef8dfa25d5eb40ebedc703",
|
||||
"G1381f81b6b7e50e8834c37fac064c264da3d5b082424b9427a5abbde12e1a46e": "G9cf0ca4046641e30a86ecfc206b095bc58c344907840e81a3f6f96ce7897dbc1",
|
||||
"G200430b184083eaaf5e70448db9923b69aca66472f45ce981e150a53f2deb9db": "G9ec817b5b48790e7655be86536ea9f3b977d4b8f33bd2c0fef4b5675eeef5bb4",
|
||||
"G6bc92b864cdc885e5b68afcd743a552a7c9f4b42d2388cf66003472867e3bd4e": "Gb8464c140da50ad417aeaf8223fe260b3f88a992c6c88bb031d72cccba48a55a",
|
||||
"G8c14a7e978b55b03be418b6d57b4b828b974b1d927bcf23d3b6b2489259004ef": "Ge0973c181b48cbf54a3f6d14946f867a0ac238c3cd39bc572f9e64601652ace3",
|
||||
"Gef6390558f110e73135f38ba3693a2a0bbbfebf79f5d05a18ac542037c6e82f7": "Gb3bb20d0b86bf071d17a94a8a97b089486dcea29956d1e4561c17b0fe02afb36",
|
||||
"G960274ddf4dea2cacd559f121430ec00ea676f7549a98477b0bc4513784755d0": "G999a566fdad5912d5ba7bdd4ecad09438215ba1cca2a7085ae7e9404178d44ae",
|
||||
"G3fa6d1586ce8508e3d479d5e78eb00fa137b014bbd75d932312b0ee70c386b15": "Gf4e2efb99bcbedcec5afe6128ad9b48c2329bc604e9f3676d8b77e4bc68708f6",
|
||||
"Gd296f58f71491c79d6a957e4d1f51b0eeba55cc2c18c7cd2dd6daaa72b8c1fac": "Gd1c17cae156a8cc9993adafce8924bfee58558c51d59eec87c687c281499dc8e",
|
||||
"Gb42704d7405a574587b6e56732f13326c48f56b9ecb9a8ae5ed245d56999dd46": "G661132cce7d7b205f4ccc463073f7eccdd8ad0700539f5f86fff216be4a62799",
|
||||
"G0ed4c552766ea82145c8a9fbf4ffa1df3fe5645c5141db3f48ec7c5af0513c4d": "Gd064ea10a7d7a330cde62b2047047bb7116f769b56082efb3fef7a95f9c0d125",
|
||||
"G31d3218b8e4a5f4c860d9b2f29247472cb9481f8b81a0e2829ec6d4e9ec1a040": "G34bcb7d0ec46bf486014ff7cccaf4b059572a58944d2c31ad628126ac8826358",
|
||||
"Ga5dc820413f88b58e703f615324e0ee07aced954a62a601b42756bbefd648c60": "Ge2ff55571ba96a533f278c0536473d0f16379e306b775cc1c63c20c5c833d201",
|
||||
"G71bd61d5846a87127e447c0e6be298e320b1ba8547107b4c29ff1fbc4e976e94": "G16d80e16e4657d413c93b203c6938432159f53b93dc05ead19dca8614b5f9819",
|
||||
"G3be852df2c79b97a22ce7b5b3c04846e992d0a2a989aaeac89b668bd4ac4a3aa": "G2f376d12b93d701297b28c53b6bbbce4b44a61156bc739288a2ed70fd4f6c5d1",
|
||||
"G39cc151b00da6e08a49d2c1ffa28b376e600cbb0d30ba369e94abb4cb909522d": "G48490053cd8c865b0b7341b8728f8dcfc6b22c7a93a06cd14b328d0811e12b36",
|
||||
"G69167cb3007cbc10ff3f56435cee6133af5a3ab44d8b90f482342cb277942c76": "G53a3bad5acd46acc7ec0334bc4b4b2c317583e398e097bd219e148ccde0541f1",
|
||||
"Gbff5754db52ea104e33fa633c50646da699ccfaefc63e5b4999ce389d0db7ce3": "Gfbab43227c6ad53f49a2333114f97412467b657164750f0f5288a64982f42eeb",
|
||||
"G8621b92ec1aa8a15312e664940f216f500ca6a068405ff384e3c4fb846b81bfc": "G19c09c3f23a7f2216d7a0fda3a525ce614f772a4fd455ccf40aa7f8f7a26b7ab",
|
||||
"G2935018dd82616079e0c56953f0de19a01afedc63b07c7e83003f9a9c76d17c8": "Gd29cd861d7aaf7d27050950e69126f8f92813e96804f440458f3009a4079cbd6",
|
||||
"G298166932cfa50abfd5fdb67f5e16e5a46bd9c8b83be93db98147156bf82c872": "Gfb59b854a2d0f8dfc56cab487f4b43f4098cad073bfca42b99edb125d20e9c61",
|
||||
"G37750486bcc1c26f93958af699d0e1ecf8201e8a021ea2e17bc25707a87ce5a9": "G08ed442b3f0210371e99276442281c34d4f6703bbb301a229502ef5054f1ffd9",
|
||||
"Ga5db62596cf669c2593b8b03329e6608026fafc35ef5c2238416bb6a28b9f62a": "G2be92dc54829e1da6016d6145b324b1a43e1ecefca262dd4bfdb68a9f35cc43f",
|
||||
"Gbed2eb0c5ce16d22ee66f0c07b921ef9d96bd3fe80f5a943aa52f0147d889e68": "G8392d2f9da662e5a7034af3e0264bb447e290d539ba4dc46ddc185f5063965d5",
|
||||
"G4e6cc043b09a7cb0fa8b7ae3b0c970f5c6de6bc78ec2ab5625785586ef9fbd4e": "G786aa0be57281d82ebe04aefdc20f4722bb6ace8e9bcb8768db4db03b1375005",
|
||||
"Gbeb7bbd19ed0eed65f8166d02a33091b8ad03228fc19ef560167efdf6a07f33c": "Gf0c07a8501880c2fe56826d3c8c852563851d40b47fde07c1aeff780a83cdc2b",
|
||||
"G2bb7ce1fc898f028e3a525ed15bb991718136ce27200c1f8a59ef959df239589": "G8eb9a981f9671033f078269ac98b382f46f844649a30cea54b3be26332fdfc47",
|
||||
"G9a06f0cc8758cc19dce70924849e633dededec6022aab385ced2b887d4cca597": "G0e1c4e8dc2bd01a3437ef728654b0edb63ad1b57f869e5cee651c1fe289b2b97",
|
||||
"G9930408e8aaadb119e8bc22a707aad05dbf362a60c7bd7e445b6886ff2831d3c": "G0195d9b1603ed74eaa038f5363cdd676eeb63b30a386746cb6b349aad6f09c96",
|
||||
"Gcaf0a44d2509e511fa767beb1638b22a584ff1adcbc4940f1e598135fc27b736": "Gbd03b00f85711c94f00fea90089076f48a9cccda65f23bd3ed3f4fb35e01ec8c",
|
||||
"G754bf911321ae722562c301aa00487fb19c0ae4271de27a9733a177ed63df5db": "G8e816f07a69b9ef1e0004ff1b0cba95fc41149a4b01c52f98f20f3f00ae07be3",
|
||||
"G654f58332e9a852e4a9b5bcf77f8b68f722afc10580beca72b5b0c422c7b2ef1": "G6c8e79d4902b4cf743a8afe439b1a1e0abdb7f8f21eb5039c6a629532e60a906",
|
||||
"G89cec4ec11dd11288f01c807ce2ff0f52fdc75aac33ccdbf1f100f3f829620d2": "G57eb13343a058c69a5698d1ac367b92fd72ceb3da411e8dac0398a0768a5a4bb",
|
||||
"Gad1b5c3e243dc1255cb3d862df58f116af45c5629863566118e3a2feb0869226": "G7f083af0e8e9947647a900cdda9c31d034508dbaae52c0409443a3b3ad21d59f",
|
||||
"G0d4820d485f9cffeee870edbbf8edd923936905e9ebcdc50a83d23de7ec6a159": "G3d1c87e9352934da393602ab72e09d975ead0e6967d11f55fedb62c45d5859f5",
|
||||
"Ga35dd5e0aa06e9c44e4bbb1112619474756fbbd301a6e7c642b817c62ef0d455": "G83e9153d064fa975a7c189ce7e881fa9f086350f4499aff3f3358033075b75ec",
|
||||
"Gdcad64d899954044c12389197968aea20d15b4fdef98c52d5ec23153c0b69c20": "Gd2a24516591b18582a3eff64136f2099cc177215a0e3efcedff01890733c0788",
|
||||
"G625186895fb1153ee8920313ab4a5668038fcd9712fff32e6a50887be3e3dd7e": "G3663a80447cd30ab89384962e8e6b2b8c2ccbb4c7abd1c96b149bc8181c5eba2",
|
||||
"Ga6b497bfeb3e9f2255ab4805ffd921c34c0abedd52f93588035bd547ff6724e9": "G4c7474d686b61360150065ea71908db503e9052767e39c7551d7cc4ccdbd2552",
|
||||
"G20789dacd98c01463a6ddee5e3cdfe39a093b2620413091e6420d63da9302473": "G324fc7b7a4f6b9c6c5600fc99efecd473ec3485a1dc8108be00b9682bf41bbf2",
|
||||
"G2568289f555406026d89a0faa647c966f9ec9a5e97f3e73d099a6d3bce811a54": "G886a5a0c91eb4a0ad9402c05b12e8f6d3fc652d3effea71d3485cf50a7dbd8c6",
|
||||
"G82327ca3c65a1b4750ca3605caf85c7817412fd4f179c55a85a9aad43ddd13bd": "G3af77e883209462687918d0f436c1596d6e3fd6678841f6785e08361a75af46f",
|
||||
"Gda3e8b2f99d380aa0c60f8780dacf3981ad7a09324805a4d0f58867914f560e5": "G78340b6bc4ce95763f797550a2941a04f1bcfcb8df5d4091d1a9ea5c24a1f7e2",
|
||||
"G6a4ff51532d93b22dfd5c8cadd408e642f6cb7a6096eb6ed079d0e98ef238d5b": "Ga96793d89c4924914e2a6f306277badffacf0990af24b2b3674e96f466951615",
|
||||
"Ge18b9d2a86bb342b414e24a6549cb1f34a6c000d679a932fb4b6568ccf4063dd": "G472d131ccef571f9cb2ee06e7261214f49bbc1e36a119e3c0cc366c98f23f500",
|
||||
"G6a29e0bc11a1e356f7fa1328e8b9a91fcc389be160b26ed3b408800ae6a6fd13": "G09f78b3a6b607c39b2fca68a7a4054323bb750b2fe70dc85ab13c72bf3d75860",
|
||||
"Gc0bd97f34ac7a4e3e3bcc829640f6526d5bf3f4d6003cbc09b6aea147d3de1b2": "G7ac29a0cb0c338d930ffa76835b20049d1a81f81f69c794d3dcac8dfc6a0141e",
|
||||
"G5f5eff96326b85ed2bc803d9424618a6da5f656e456bb4b7734cde61bc49067b": "Ge7067c5fb15f67bac212093da736ffbd04aa13e2915ddbc4a08450d0d3ec4a97",
|
||||
"G723468dab17f8e083411c797b7cbe51da53b27b782413797a1931e90afc5b4e1": "Gacb2b831d861daf8f646169e0836465c9d1b75c879f9cae86ef70e37ac4526c2",
|
||||
"Gc08c0654afce1b73d2c02f4593c5e29acd7c0fceb40ccc151e396fb017d314eb": "G18170eb62353e8bb450fcc44df2cd763b64c2dd853d943e310cf372599b7055f",
|
||||
"G3b9b888f049bf483fc17a7eb85f344e0e5c74397e949e6e501a7b17241769c20": "Gad8194c5f9050efcb8b12e150be83682769a61897dbd16d19851f6732951f88a",
|
||||
"G38feae067106ecd440229bfb4c8bcc03ab927f237ddd6c698879238c8aef3835": "G5014da556b19342d379352a3befaff4d7271a65174dc91d8278c0c8295d2a429",
|
||||
"Ge43b406e4e034ec24a6deb6c447be7ec6678a7a8cdd59132f6f3cbf95165d4c1": "G2ff00b659f0ddf8610d606d53b02fdc8df69ff639db5c246f6c80a525c458db7",
|
||||
"Ge21176059a83eef7fa8f5fc98c329b1d6909395ccfafa15ae9e0e0120177be93": "Gd0616dd15eec949ccd49d727b22a5fd33352fdab418e8d464313b357aca7fa13",
|
||||
"G6a58f1cccd41586cd4b030644249932a622dca06c523f84184c9bbc0571941d9": "G12f2b73cd600478a0c16c0fc6b382005b3a933865f3d11de127e499de9bcfb60",
|
||||
"G0f5bd097383b05e7dace9ba2cc690094d02e13756fdba1da5b3c316e84440522": "G2b883ffdbe765eaaebfac6e83bc0081b9c997ca0e3d1035d02ddff07d7fef2e1",
|
||||
"G880c284bcbb6ae7e6cf6482c613f2e045c989d52754e0e9dd726a114a572b452": "G61da2b79d0434ff044ceff76352d9ba9748775d1e8baf43124c8ecb6b1c8d502",
|
||||
"G6c6b2c4dd15d96f336878468a5461c178f1d9bf66340468976592433db9dff88": "G1c9806798380fd61a84e0b9c110c728cca54f643eadf620373f84cf042d95e36",
|
||||
"G03e076f00f51a3bacd8d0e1a3eb349b62fa6cca1241281f12dfa2f8f42060a6f": "Gca3a6b342a136b9b6b7427ab653d76a516aadc1c9b0a7b079b98d5c04b984118",
|
||||
"Gf41ec8a91f708368f7a60f7d8b4d77690c100916430148fef0fd8d16a7c6b568": "Gf151ed2cde8b6da5011d963a3f4e593c6010fd92646a7fb1c6d6738c2920a277",
|
||||
"Ge2f036b10ebc5c82731cc1d99ad55852fa67e6f5e5daf8f8e1f9f2f5ea1e5725": "G2df81066938bb2ff7d0e166f026ec253a27fa4e8adbfe55e030872213dbb0af9",
|
||||
"Gca3459a14f5838332035d3be80ff6705d12845b6294a29e9b60722b4fe57f79d": "G63a7f069f8942865d6ddf7b51bd8c5e1bc051671c5e3b565a8b1544d46c73b79",
|
||||
"Gfc705ce59372c3d66ce7c92da9c0ebe753f8892235143cd302f1d12951224b22": "Gb6469efe0ad1fe92949ed1ccfc26115a2620cdd7be6ee041e99faaf6b571b90b",
|
||||
"G394186cda313937cb8cc70f2c47064bf9acf921b9cf31cf8260107ef32ba9add": "G71cca18c17f15bbb8920c269f4c989fd064b750a8e4748b1ad767116f38a6e77",
|
||||
"Gf2943e13de2a701a0d83872c8d162287262400b647f43f4cab66bc37b16e3d03": "Gb07e04f91ccfc1eb1502800e1f54f7197829b7d1443838f5b7f2835d5d702abb",
|
||||
"Gb7f75c5e982075bfb653516f7e3e3a99958fb9083e48b7965b1d7a30405a7529": "Gfa235ed890bbc75bab98cd88bc07ebd83f65ce5b14ce3249af1fdf9d816be726",
|
||||
"Ga26f4cafc715e6c6662abf79debc774938cc0b259b25d00397f98ec9499f94eb": "Ga9ed7c31b4262f6e5dfc58b3d00a8ccec11d0ba5e0b2e47c57b220919785b373",
|
||||
"G7ca5885c32800c012cc897dc14abc2c8cac462cad7b1f4d2f88328a1f3392c35": "Ga0695b5a44b0fc387d0fd8e7289fecb6a2254231bee6c5624695e44192f77d23",
|
||||
"G8bae0ae05088cc8506aff9900c34228c5427744ada392a4f7f9dd35c3037a1e8": "Gbbda7dfac9675109f47f73efe54520db3d16f51876fd8a86e29eac5ec39a534e",
|
||||
"G181df38bf004505dcdde1ff8c9729f5fd39b8fa71161848c4d4d6ca6d727ec41": "G7265d66249d1d9bd54c1ae06b6c1936b984beef4a154ee137412bcf05d75b8e3",
|
||||
"Gd32662938d122fcbfa18d4f8390d2772859df69192c6b924f8012093ad6c3fec": "G063ef72be4cc1cb237dfa267d9f400b66cf337136c952daf4334df7ae7e41bfc",
|
||||
"G42df127decb5fac1601a864f63044d3acd56cf95c4708c48dec4da8e83154447": "Ga56e3b9b26905c5a45da42bdd7080ba5341ad94d7a3a545b365fe17992cb2b65",
|
||||
"G75d3d875541961b03f620caa0fdb03681680cdafe0298ff99c442499cd5fb378": "Gb8d3310edb53dd5f6a5d08da0f6e18e75ebca930698e39abe36a52662718f1b2",
|
||||
"G79c4f6884c285c67a3ba9d721622fa917201769d4ddfd0dd9bfb38472d5e10da": "Gb22d2a97955f9932a348b1596b6476ed6a7bdbc1d080b59a73cfd9fba6ebb7de",
|
||||
"G7f6b492105cb229cdc5abbd6306d9537f8b0aac16c9f74db60b53a6785f9b604": "G8f28381abceaa26a9c3a76d3cfb7a630450da1dddb8cd82171f9a7b5173b8649",
|
||||
"Gcd004c1887aef0e7704da2ce50153b06706a6ae86a68f747bbfbd8257bf201eb": "G254bc6d16c8ed54da6d7ad88903a46b8500d24049537901e070055347379986d",
|
||||
"Ga0df6083017e36eab6bb9ebde9b26776bb85faffd728b1c54a3b205cabbac1b5": "G4b25b28d4afdfa58fee55c84a2963e1886886ae515ccab6274cd66a601b55c24",
|
||||
"G55dadbcb465cc58b4c949826ca0436e808c259e909a1108143a4710e5b8b352d": "G7c5055482429522f7482e7bdb97036d9a2c7e93f294ad4b39660d8e971884c29",
|
||||
"G08fd73ecdd710f045816430e5cbbe1b70d2053e466cf3153b4564993b36d8ba9": "G60b4be3a6aca95dd43fa1bf62215deabba42f7a40ab3e3eef78916705d0a38ea",
|
||||
"Gc567056c12238d066060d988a8743ec2a4e9c1d09ca44d6dadd08be8ab603f13": "G332b29e41e4069bf16d5b4718408c8a8fe446aff4492c26b7ab4389a7df4286d",
|
||||
"Gf53be8d6288499987c8136e0d96676662d57c929a1820303f47a18f1d9977ef6": "G89dd2e38a992365ad2f80eb39fee7e686cb13c3a9a1343b266913fa63afa5a79",
|
||||
"G2455689e65bc890fe6b768786ac26bc1ecc010b7103888019a131b6aaa9a6828": "G86ef585d023d76a34105a87a29532201fa5cf0977e5b1bb7c4cdbbb2bc0d7d02",
|
||||
"G2273a629e2c47d4e4a05cd86bb2dd07020945dfd1bb0c2b6e62eb3e319d67280": "G45fecf25975b96b93ce9c81fac3fccac618516935bf278c025e5707312de92f2",
|
||||
"G653396c9ea159a062a7f0f7455cbf13a100fdaf48b6aef95e7ba98f1f4e01c3f": "Gb19e2023305b87213154c3a28e8c858141cf3fa113b6a15815b5d2b12009938d",
|
||||
"G5852c506437c45c321c465e787a7226ce6f018f73f7299919264c357ef70fe82": "G29a70b3f17eae4f3b8256d0d832470d278a48147e839d4238e8a082193a96502",
|
||||
"Gc501abbcdb1ffbfcadf48e69a49ed5c8822e5804a016223acd727f24aff037bc": "Gcf40d565e86bf83a3eef2a7cb82deccf0addefa7221e7d9d4ea81d0d26da3d8d",
|
||||
"Gaab422b2ec34f6a3140193a0bdf62e7228d28a62461b1894de6a224a22e7fed7": "Gbabb92af4e18584e9641f97cc7bd63ebadffa8ca1fe410f56bc8cee0b8f308bf",
|
||||
"Gd4d221f92419a6d4db84679512e9484023dc0c77dc911fb0af5332d4049895ff": "Gcd568e1f0bc9ed6c4c1e2736f7cfda2eef649931562bfbd79b4e40c07c8ecd4b",
|
||||
"G7e4e5698240f19602fc4775b101507f1008d6726e7d53b77699d3d41201f90d0": "Gfaf0ebec02f07c36c9f09efe37acd925cefcebbf092ccc3b40a23bf3b072835f",
|
||||
"G2267969a8bf2299a24209062578dae910a343d8aa070b815aecd9cb0fb1da8e3": "Gea588214e18bc3879ee62164eb06c7356230f35be3ae75a71d83d01645532628",
|
||||
"G80d42b930287a1ef469f86d18d062661426b46e1f09c5591fd5380b93ae4f7b0": "G9973d2b6a16c70d30b0d302ace1ac6d523a4d6d7d1847134dc3c67d3fde218e7",
|
||||
"G161dbccdf18052986d896dfc5323f038c1abb36f2e764ed46d92c2e90b3106a6": "Gfbbd23ea00d0c1e07e787e7f9e8a969bb1d3e50016037e7f831abcc8a76051d0",
|
||||
"G096952757cb1363652f1c8d7c6a4d31f8ca0ae627f7e41568614a0bb8d283fe7": "G7a1c16f3d0dfac597511a5ef7f102b8f05f1859aa5bb347ee980e6ffe9124153",
|
||||
"Ge99fd25f71e64d91771546fa9dda6b5b59f2746d2259398f34d1f3c63c9ec721": "Gb372c466966afbf3ebf70c5d3b701dab56fd376965797a6864ed163b20d2868a",
|
||||
"G1a51c71951ad0fc9f1e7eaaa32ff6a6c25f3659faa0f0b63e1440590352fa850": "Gcaf664e666e9d567941bddc60734e270cc273456074b66fa81921f0718419584",
|
||||
"G65f1fb8f98fb1f8da4c19cdecf4b33cae36769e9354c825231e1738e3c27eb60": "G24421c36c33c601a8ec08fbc4de2713ff7fdbdf14e088fe0a6c4cd640e544d79",
|
||||
"G5b4244720155901e8b3aa8c2143c561d88f8fce9fa80cf619ae5111aacc20694": "G2788eba138e2919d69fc1c78da2af3d03d0dd5923bc816adcd13d977b9a07b51",
|
||||
"Geb56e070e7fe0fbf39443b4e86c96544b6e9ffb1a055270bcfe4152b5a8b0e89": "G16b48da8eaa90d33762ebdd9157f318a6796c18233c2b7342fd60342c19f5a84",
|
||||
"Ge89c922fa298f38f8a478219be94871f084206c3f6e49b9a8244c27feae5c5f3": "G89e418fe4063bd4ec408ab6f14fc2adeb8c6b083b78c035e86baa7fb08c49be5",
|
||||
"G8f8c8fb87a12f4074370ca73e1f168d5d9d2c4e5ca3a2b4d7fc4a6c5e80fb82a": "Gf6b5d259f395e82c4a0253318916d1ba879518018e59db87bd074794a1b4f744",
|
||||
"G1cc8b945a0c578e781694a198d70a4967367868e959b0fcee177700cf2942ab7": "G7036ef4e689adc7193a8e8eb21e35fee512ee1bf77f936a902e703da6039602e",
|
||||
"G9a070f7500b05c13f92c14d8cb646012633394110e10de7cb263dbd721b66b18": "G167348b8b1dd68884dd7ec4c7889fff8f71ff8d96aece8aac2de550acadab729",
|
||||
"Gd8f31713af340aa31482fbce46a07af342004e310b744c6e5b516d7385a57502": "G809602ca08ef16ac69cbb376195cf646e9a259905283e123c9dff879c6c13594",
|
||||
"Gf431ae7eb6403be496d0d3bcee22bbcc503bf48e721e8e5a6eb8dcc80efbd08c": "Gf8ae63ad9fb57853c7431ea5a8ba962da3e2bd0d29df547fa9239642acd1aab8",
|
||||
"G378188b4d0c49bbfa1284d1155ae558dca50abb5a245be1c37c5209ede57085f": "G03e0b772b28192e1503552d94e7ca9785d79e74f6682d922b3d8e550723c095c",
|
||||
"G1c238bc8ca9b8f89e1eab6789e5a60f5624573a9b68e121044d50344cb61fe42": "G712841f80690c78026f34dcf8ed885545168bcb065afa9987038c577c703fe0f",
|
||||
"Gbc23d96a89094c2c3bb6366096f6fcdca45b30ecb55192e2229e4fddd0310faa": "G46b5ce2a764563eb431d3270836e49fd911c64cd502495d10bf2da1bba4dc01c",
|
||||
"G33a9358057928f0ffb8b5691315eaedc74b903fa717158d0fe077eb3eca3e517": "G04446d20628c449092f4e5a74c5416315fde66b6fa9e9977f6d074619451f6bd",
|
||||
"G6a1d19018159d80457f7045b7908ba2a32ab4889bac33153be140967f7cedcc7": "G911d105e08b4c74f2ddb698efbb952b51753cb7dba4e35ad512e6104fd1064b6",
|
||||
"G2db28d9a6929d00d781cb8134d17f3c7b683ecd6bc389984240fce4dc0475403": "Gab1c4a139570cfbee48db6c96f66cd39486d99911771340f11e05533aa4b3287",
|
||||
"Gf3b0dfb58f116c1c7134db24401590403234c478a10c076758543f29b2c235bb": "G3b2d3447cae04f856cb6c51a4d924aeabefc2034c1dd8a823bd134c04cc3b9db",
|
||||
"G1e2eda29c30949f974cb30a042fe5dc78551d53a76ded24fc46cc4b73845259c": "G51eae5902c3a14284b515cf85001a6e94cc195f8b408865bd808074a11b0f947",
|
||||
"G7f67f44a0fd3eea6c22c83398ebcafd71136248f0911fb8f790a6d4c837b01aa": "Gc275e614ee2303f13e217d7883a35ff4d3507a96f58db9e701023596c91d9a69",
|
||||
"G0ae78134b5a4c3884353d6d5900dcfd12f8d53695a7ce9691f9327edc86cc289": "G84b57d80510556b7aa49b3c01d76d4e4c29521d70c34fa97647a37676ea9362a",
|
||||
"Ge0417bddb65b1021a308a19495d824130c6d0b1ca23e606249f316bb21f2a43c": "Gf5cc6c22777f61ca37c672458970ffae60a6ffb238343fb0b22a838bba83b9c2",
|
||||
"G47c8f8f25c445a4558206e8dfbd429068349dff7abdd0ed482c7d3900d8e6f59": "G15ae6ddaa4b63de46dd2371010df7864752996f8e904906faae0feb72c511d1a",
|
||||
"Ga1bc389b21e3012240f33f493de6db7efd1389a372b67f6fbe9856c400d29f0f": "G0d44be3bae50f7554f0e696e39b726926f6bb750fd49426c848c84ed9bcbbaad",
|
||||
"G00d676101f4850abd718e1fe908539d4f91ad4ea58f73af7b9cbc52544e74684": "G2a931695fbcb409c4e88590d1af4887a4b41c5787c625a45b2ca7b3a672fd034",
|
||||
"G22531dc83f001d7bc2f73a28b7a3d71f2835b174a2eba151393319b710b375ec": "G00122bef622fadcfd27c3ae3472bc8f5a929a64ad5dd62f7dd7e6c67e595766f",
|
||||
"G873e92ff8bb8352eef55b97e11329e216bd2a037145e0385147b094bd592da82": "G39e38988b5fff299596322caf2d7e522d5af8c373560b3bd592fa58992a67a58",
|
||||
"G5bc76bd2a019f34bef39f4a4cf731c1d726a82abb47b75834f5e7ce200af93a2": "Ge1e31f0d0cbb42a4ade9ef4955f2e24527fdd26f43eac8d5be6f8df7b897d8f0",
|
||||
"Gac4eda4ba8dfc2158542ae76bd49fbbbdea750f4332338cd330c1f4c8bc1a84b": "G1d5ca7dbac64333501d2addcdc5204b8f67ff97d8816b31148de032b3f88143b",
|
||||
"G25943e5bfe8824d0f67c282728f9cd3650666519e71aacf8a4273cfd5d08b8de": "Ga5f928c598496bb359148b3a369b2c39803a9e65829150f0719b5de89856350e",
|
||||
"G3c37a44164e6ae1115dd1ccbd67a54d4131a15b228e2afe0d9f271894d8629ef": "Gf9361811b028db747f42efac21279b2309cc60b6c4928c2995f7d5dd44d003b1",
|
||||
"Geb61eb5bc6b35cad82582243c0cf6879dee5a1743c78645ca625b6de31ca4f6a": "G37a14b574a6fa69869b81853cd18acc23ca6cc8e5a0e66ba52bc50d2adb37bb4",
|
||||
"G28366badbe5c87fa61a5025a58cdf8f52f14b3330b19083567de401115f9c72c": "G7fe33015d02f9b19626121a96480e12cbe06905f3a33252b64ec9927857050e3",
|
||||
"G8f9f1a73b569a99206b8a99461d713dec97720bd049df1f8e433632993633001": "G26c265e7188e5a1536a818eb38fda52eaee3bfd7e71b3e1f029e0262ea6a782c",
|
||||
"Gd837975332349aff6f091232ebe8e4275096bfd5e8866eb96f8f7988163d5ff6": "G94c2a3d6e936cf946d7b870caaed917eb9a00d644f68ac514387a13a1356b2c1",
|
||||
"G1fa2d8289b79aaf5a3067df209f175eedc834d18b34ed43d3e62978fa6302e32": "G909ef14ed7b214a25332693fe623ae65e2e0468f91def016d7a77f8d5779af46",
|
||||
"G1f62ddb6de93461446da5898c67392c8030f0c6bad3a11ba0433565230f9fac6": "G604f3063c34494062b11e181543cf881d408b67311695f750927b3f907e75cf5",
|
||||
"Gb4c1a4e8e178dbf99d0ed3f5c1a59fc83d88f93a6ab0b4567dc4e70db837b2c6": "Gd95a09ddc1ce7d3d139c7c622883d58c5541127c94d3fea1c3828f912ca412f0",
|
||||
"G87456157681bafef37d52d99370088e547d5e9ad55fd45369e12dd2bd3895a3c": "G6f51d83a87ea6468d36357d2fbebad8248a1db01503d409f7bced22a17d21199",
|
||||
"G488d565d68431ce46422f085f04c04e80366b57a64a025e8914ff8d0297ee382": "G16448646d8f0ee92742e50607460ed9ab36ce77078f83336592c060360326d59",
|
||||
"G26df4f768894142b4161e7b4b362a6db09bccae563dc106e0de662c10924c88f": "G39a50e57d5a90612ce18d55ad410299f854a5d88c439582fd51facdbf9d338f1",
|
||||
"G66acf9104f38a848ab38caeb409c7ccecccea02a6b7c1453d5d4ef4caaa95b35": "Gcfebd2e376a3480b9e6c033f117469b765582902c36339422ad9a9898402ee1f",
|
||||
"Gd20bb734f7924f085951fae9e2c019d9e63aa010e617ae92f81b9249e56bc172": "Gcf2e16e52eeb5b2c5af03c96e37381eb1a2ca7ed1e5941fafbc297a5553381b0",
|
||||
"Gfe5cb6e3f57cf27279b3f79c99791fd5c30acb5bea982f34a99ae9c9dd7f5105": "G60ea64a9d5c8db898668210740f06c79924b37229dca4bda9c6908b2f958bfc6",
|
||||
"Ge0dbddf1ca9773ebaf2ee8d1bfe42b372eb6b3094c2de1b0275766d326daa706": "Gfe519c3456dff488fd95160e05e82566c09f994ef97daea3342ac39b7e5d73ff",
|
||||
"G4670b732730d59913fab058592f9c12e1d1c1885f5342faae6e64aba0c6e855f": "G60e9c01536ddd674ae7da090f6fed232d8de0358a0d253949d780133d12e5bf8",
|
||||
"Gd5be9b5c9a426041a9ca79acd1fa0f371b0e524898e6ffe5f0321b5f55fbc67e": "Ga6d0023afac9a643d7bfa255e678433ff5d320d4b3de803df57e1385e97a781b",
|
||||
"Gd6850c9703150082ab42078509f94a098ceafb612b6ce4ca0db8e94b357ff278": "G93ee06f15bec2deed7fb4c59221da49da778147972ebc070d3602f8beb78c13c",
|
||||
"G4c2683550000efb9ceea5b1b6f4d67444f2487db1d3d38bcea144f98ebb22f2b": "G2753016668b125fa4c054f8c5044e59ebee545a1cb4aa50f9a95af5e2afd600d",
|
||||
"G9113ca9a758f555f24c8ba3037da9a052562b3d686e6303027206572389811e1": "Ga70c91a1d0c0240cfb3c0b3628b4b00e4250208335e09d2efd04cd137325d789",
|
||||
"G28c623459d09d203b4220d0419835925a486ff24b553c70910b34ce87d1e34c6": "G19bb3009f3b3af570179c740657bcd8ce468e0ae1a0cfddf138c31331657bdbc",
|
||||
"Gd65e62814382a3e165c32cb2be3c66e4d6a9428fa9a4970e933ac0a580afc585": "G9f3ef95935d9c7f41602019787938467d1fce5294f7c91850fe3d5840ee13fce",
|
||||
"Ge8ea38a06a88f3bf81f8f59bf39290b53f8d8a62e315dc957eaec03b7b8df3a4": "Gf5463e029bcc05da520e5cd0a7964c2cbdc36618c46506212fd5488c22679391",
|
||||
"G8145fdbea66519513f3aa8f47c191d8029d522f08e4c1b14a1694686373fb781": "G9c1ef5e70be5684ec6d0267e0fc0c526ed9d095338d7d9832ba4fa225daf12d1",
|
||||
"G68fecfe183d3d874fd24f1c1f516854d79d643df0e6fbefb64b50d1ddffb305b": "G7a12b47023c873d34f89671f2dd3d967af1fbc190ca6565d3a9d4e47a764372f",
|
||||
"G7998550b0215cb2dd7a7fd39de62577972c95b69e127a044362ae2d09157590e": "G4f2f1e9c58fe75fc1d4adb980abbd4ccef0c8e2a458eebd2dfe5c61e402a1ba4",
|
||||
"G39ecef606cbc725421f980588673387079ee68fcbad3f0284a65abef8cd1f867": "G7107e4419481cd400f06fe820457cfcc57868665d07f987ef19b129432a7ebf2",
|
||||
"Gd0acd0dfd8f9f91ae9b6aa7734fb726c0a8e83e67d7771d8c72d64311708e750": "G6888d8c1566ce138427dfdd51ff1e7ebe5a7272b1ebf5ef88c119e0565f2d69c",
|
||||
"Gc3663f8188539baf2fc8dc21b5be903a9086ca25367b8311db9f7bf0f834f761": "G943e04101cb28a01426725ec45387a3a9de9bdbb999acaa0478f2e482c4da76b",
|
||||
"G5a250fcecae6a6b81897f1995aa49a885def0f49e612d0002d401d5f522fd38d": "Ge8bef7d934fe71987f467f45f1c7d335062cc66470775c67f85fde23e8eea88a",
|
||||
"G7a2666b1e2568598202eccdcf1de4fdcfe40812f4a33e9d8ca4e2073e5a7e864": "G202700527e32bf7de07b254e7b5c4a3629e8d15c428a5a1f22d2b20b21232a63",
|
||||
"G23c50bcf3db50731d51d3e0c33d2572c83fdbcc12350d9e11190d3566fae9032": "G247303e9e1b7a1c4863c6024ec6e9bba3e8da377cf8ea44b925b296c98594d7e",
|
||||
"G3c62ea2e1321ac2629743faa53c42016a006c714881cd99ec0437919285ed5d5": "G09ee2358ba001fc10bf09472c2f4b1edcf4a512c9a77661baf3cd37464ec0584",
|
||||
"G6407753fe57a62da297fde4b8c638ccc14211e2288c1af3fb575784e4e88d154": "G56caf3712a9a9805e1ee8eb7fe86673b64314baad10bbfc0e8c86b679051b444",
|
||||
"Gcdc3cf2ac89b63f88e687f718102c99ecb24769ea33e97ed4642b0e8380af63d": "G698f0bd2b5a3859478a49898f499bad6c84ec7989cec5822b5390ae33fcca96f",
|
||||
"Ge7541ba6c58aaf29c0e66f1b2709ffb88f558d932caeeccf2e4cd1af5f94d087": "G65278d2891251faf37d35823634a41b75f05843239b9ec80961e1ffc99f023f2",
|
||||
"G722a7884a3552c4e449b11c83ee0883e8e0d3e246db5c53ea1814cc295784c91": "Ge7914262f1dabad65f3c12a74f2fe9dc72e622e35bac5a5ab7da601907687c8f",
|
||||
"G36a064626e4138a85f72f40a610fa4b320a8627d7b143043f226bbff50e8d457": "G9096ca58f8aa719866a945404c0a03fdfb2ab9c6c0cd5628e8e82cfa22ca6162",
|
||||
"G82d70fe20b48a11d416a61ea462a0b8cc1bd6eb4a9b3eb545a30263cafab28a7": "G780126a57153930857d9bb1e3a8aa071f60fe58bf3dbf11a49033b0f249fb483",
|
||||
"G8abfe67c7680a61f6c1d1e98906475f7beacabe66e31f5845456bb7fbd13f0bc": "G2f368ae70e177729fbbe2b809198761ee4e374e8d64ecda15495df7a40e02cf2",
|
||||
"G8cd2cfb82e8a476a66c7a8c3f6b60f7958a8acf2a4120181fbda9f59db564e39": "G130f6090577b551ba3a65bb2da44e5807d407c41f9dac35aa4ed259e0eb21d8f",
|
||||
"G67afe3fcc2835cf40b85cf1253a352e9555d1ac66336d6482581069dc032232d": "Gc0447d29f102fc5bec6236e7cc2652568c0c32e01fcfb5f1fbc78d204b91b0d0",
|
||||
"Gcd4e04fc14af725f3d1ea3671e23adc4a5c536d6fe9a7e9f6cbd87483f028fc7": "Gf1dbcfa03d5e4496dea64d2093673b70762d6d66a3c5cc19cb329bd34d89841e",
|
||||
"G0b172f023b29fa35dc5c18dd4a764435b4877636c1d99e2960e91795ca83dd80": "G8980504c85d097d4b41a7a803ae476a050d2f82d00cb8c648feee4abc228d64c",
|
||||
"G3ce2fa38830ea34a4c106e80c57c082fb71e6a0f13570e3877d8d116c325de20": "G1ba1112c67f42f72661953a758f9d0fa41798e73337a2e42851bef711e489f92",
|
||||
"G37eedc8030f77487c846f6c0b47e51e420501991ea5b2a6bcf1b2c7204f203c0": "G0f51e650348ed098980ff3ef3a09392e01cc66279959e3e6dde3cc502ead527c",
|
||||
"Gbb19cb56b35c8e0c75234944c7b3499a68cd0db804b9812079d2f4b8e4a2ebab": "G1a79132331135eca1bb77173e9ce844490b4624a7ecc5d7ffae02e40f4378404",
|
||||
"G3d3c2ca6e7e7ca8c13b6abf0fba5f35492a2e6e0ea3918b4370fef8575e014dd": "Ge06603c39c07860b82c2d49cfe4303c386e8bd88cfafaf991932963112fbdf39",
|
||||
"Ge9b0dffe433395244dde749564ada73c29933739e8207d257bbc94c35642a83e": "G3bf0295c50cf8431f0d2c6aa13595d7dc4dcdf0738ed4288453a9047c3f1fe86",
|
||||
"G34ce6ded60134f35b48b4b9fb7f32e0757f9b2c254ba14b7898f93a49c03433d": "G475ca03a09c5035de96d162dbae307e016cb16f14d7215817b5fbf613c88ae35",
|
||||
"Gd11be9670bd13f96a70dab4ca5f60d0d929b9cb26dd35f9c2775e539f68ab491": "G044dccb6b25fc04451e9100bbfaa3b634dd3556c161f7b4a6c1da40d502913d6",
|
||||
"G685db835351b0918d0e71aefc4b10cbfac7a9e7e23294b99423eac41ce63df08": "G0a1437544cfd3bc60d98e11db00ec4aef55df24454e63fa986d6de3cbd9c02f7",
|
||||
"Gd04e7f4fdee9c4173dee2b1a40111f88617c0d6cbffa4880c26130e1c7969b80": "Gfbf6843a90e7657e22782f9cee31c2e6448047a4afae1432d7dffc6b7c1f8305",
|
||||
"G3c76b02aba28d133f4df7d4ae457d990f73a5e9d85cb1390d7b86ef4e6134151": "G1bd1da343629dd0fa15bcfe24cc5cc0e67f0d67b255c9528b0428e47be9813ad",
|
||||
"G31b1b568376afa7187b1ee3aa89eeed0b8d0daa42cff051679511bf6856e6101": "G0515f7a6c498cc4ff2712a4178010910de0454c135b1fd0ea9710a96b019966a",
|
||||
"Gb722c2d8c46d04ad1d0d4147b92f42b2dfba8cb5cd0160fbe1424b2f93af81b2": "Ge89577e221b6a55ff840aebd8f9ba403f6e219f9d034ea835e978292ec1cb479",
|
||||
"Ge120b5fd900dbf3d2bd80e08dc8abd58709ea5d9ca97c7a0bf3e916a02a1cfe2": "G8924c90d4678ac3d0495f6ad67ae4ea07c752a5a6d28b2629f26b8d98dbd9e5c",
|
||||
"G6783d25ac9d92c0bc1927fe553c50cda7bb878748df6275f4a983e67ee985248": "G607d0a0684e41637139fef8ff230f41bb909942c9a94f57f7d0485a0628264ce",
|
||||
"G984819589a24c33cf390855b5ce944ac5505632b912fc30f6abbeebdb08ecb1d": "G72bbfc671c0f211355085393012b9835ac65d8491ff2431f878626f89df7eb07",
|
||||
"G446cf9f06120cf2bbbdcb991c949d15943b1b4324d3d3d20e478a07efa9d0f88": "G90bcbf690a855cc9be365f22c9fcfe47e1e73b158a2cc2ea228e14452825f354",
|
||||
"Ga18826414797270c316fd9b3838e73214ab45478c317590fb3cd9d9a0953aff0": "G6358c8004675bb53ae6bde30a8cd93735aa8fd123438cd93256d70fbd6f29cc6",
|
||||
"G4a4220694788149fc08096febe84ddc584b26b6c9ab40c4b0a46bd2acb897b20": "Gfb2a810bd50e3d3813a6fbd939119c340bf250cfa9843b65f61bdc8301b74ef9",
|
||||
"G77a7f616a426fd5d305ab73ed4f4cbad6c22357a9bf20d0fea763e9d2c0ca2fd": "G4a159b7973fabd82ea7e51bc96d58fc94df7fe15077769d711a155445b9eff63",
|
||||
"G380901c2d644407924611992a93ba2d23709366d261e73d671561589c4657df7": "G259ab31e08b751f67b3fc496de2c4ae262274962ec5589e2e4a2880b70d96857",
|
||||
"Gbcae3cba420afc06b123e43ba6c2305d14972e0f75ce87bf97dd09fc9c7381bd": "G899d13fe5225f7fca4cc5a3ad77afe02ef7f03a874d3ee2ac5a1a177b9240acf",
|
||||
"G53b10706ce6041409c4921177d36d0d700e8c9366efe7ad8e34660b07df1087b": "G9540d88dcc938ebb0ef124b5f031eadc0966c826023e9719c59e349487cee00d",
|
||||
"G1e61ca0b8b1941e8fa72261d8f08285fcdd919f8438017a46bcbbb059961910c": "G1f9940b18b32059b73851cfd2d813ed952f6ae55a917fba2f2318091d5323df9",
|
||||
"Gd39322849e696f84aeb5f0a80ec4420ee56c6ef7c05fd430c436d2fb7ea2bbdd": "G00b9df1cc7183235c56613655d77b35df8a9716a4ff456b86629269fa579a1a4",
|
||||
"G2667d24e2fb0015183e6d9d9e0bf277e1026ed2b1020d1787673ffa6692b9506": "G465cbe195779daf0dd8449ead358f77dd3047c01538d8a8177fcca7f6c7732b9",
|
||||
"G05c2ab5e047aa67b80ff338e4c4e108f784b129105970a8ea7dad02bb488b3ef": "G9957c837ce04f4494df641b057cfc3161fdc8a4ec9898dc136548f46f75bc41e",
|
||||
"Gb287f5494eee0a93e4a6f890546af25d5ae3a4f2e78b58f2fd31040c40fa354e": "G0f88e481ccc93429f62c7124103bb0d29c80eaf46265cb42ebf873fc1cded1ef",
|
||||
"G56a9563f7da4e29b97be8dcdc95a384e7bac136566e1170c9b60d38fc79314c5": "Gba598c9c26a325bb63a54c9fb6be633ef19f4c23e13f569204d3d586a4b6670e",
|
||||
"Ga08f0d8f1b4616acfac36826684318e4335f31106235eca6fbda80bb7dd36018": "G13a74d151e58d9e4edea8c247804cacc51d435c831e355408ecfe4aef8131942",
|
||||
"G42f1df315e5648de270061cdc00adbdd07c4fcb5ba34dee344063e94b076ac4c": "G6b92ca680c48f3df1ccf2772438f75abbcc1de394ec6c2ddd6e5fd7727cfef63",
|
||||
"G32afd19762c11d926ae794f659b99c31a0c21097866a792e95b931a49dddbdfd": "G011d1f17d3a353d69ad87465ea6962d8e83843c4f6cf0caf0e8a45792800ea09",
|
||||
"G0e1f2bf6e45848b28311505260a5c04951ce9f5fd199363e5fc8fee420c39a17": "Ge11fd188ac70c54f6b37465777598f77980ab49565ab206118a5b85a73d47194",
|
||||
"Gb29e1c2989fc5cf181ad34af23025d56a39ba5481e10f67ee468b229328b404d": "G8bbc3e020dba18d3d294f2a51f722e1460b047324a80b15c44db4eb81de65729",
|
||||
"Gb00614020a091ce7df081bbab49e16c50715f4d9bae0fb4a643c55ee3d4ce88a": "G5882d96f0ef38eafb99037fdfc4e61a1fcc0038d5c19bef289f5ab055163f83d",
|
||||
"Gf827d9b6ab5f37ced9ffc825bf9bff85d16d597a6f49c4008c771d38becf1e95": "G3a85c5204e9708532cf7f0c038bc6e65cfc929a40d090faaf361a07fb6b8bfa6",
|
||||
"G0d74c382453d4fea5706533e1d748519b2b566351eb09a1f13704630c20eda31": "G10db40bef5ef910ec3b247621889e66bc431220746a5e536c45d4f8bf0386f36",
|
||||
"Geee0d6a1d6729fa68e90d32453453f0ea89d18d98092ea65db8944d3d9923b74": "G5234d7e017a9d520b2714f84bd66ff9ac28e04573e4afff04249bcef6ed7c0ed",
|
||||
"G8a9df565dad7b7eeb7e4f55328f0a0a218e807792875881c3011a5220f387f0b": "G10b47f5c3084d79bc92d45e616dcfb7dc3a7040aa3155fd49397352be6f5b6fa",
|
||||
"G06c67b34e5d84643f0a6b863f7323a7497483d96c92ea3e1018d1b209801534e": "G582b57b4ce8cf63c44690d329d9e80f9701a841ca3f8c8dc291dbdcfb545a38d",
|
||||
"Gcc178c0d77ede6a01125d09e2c9df9cf6257620178b00a7d5a791b1d532bd737": "G7d14143c6d71c5a9412d4ee87816eba1b926e1f9e282d482cf4c8f8c1bdaebb0",
|
||||
"Gc987927c7c4b07a08d4fd8c8bd21999f7c59e8dd7201510b2dd18aeb590e30ff": "G04fe718f0483fb71361bb3bd82d42674b25ca069823831c8907f52f7a68b88c0",
|
||||
"Gb454a482cd31c757cafd931aa41542fa60c219c13d46214c8514c10b5c792d59": "G9c178aa1967a795a17a9eee9c84bbabd63b4cedc3223c5c6fd245839729718df",
|
||||
"G0c761389ef0fbbad848457ddd0e98fc55c282180271ad16ca4c9815846fec994": "G54b92c7e85b819464c7bb21b9c1665fc745c13f289ebe2946ea36bc6f0e074ac",
|
||||
"G9cc798bd379dad47ba50176b236b09209a448b6eae397e932169b0552485e2bc": "G647d4f2a60cf456a392b6f062725978732d1723ef93b0cc2dc1bb75302e9479b",
|
||||
"G15163c2d1ddf5856d12492deacb2317cd48c47bc325a61f515dad052864b2158": "G1fb1cc0ee9612ead59f8792d4fe28ba527af5fecdea0b2198f9c7ac2c4d2abd4",
|
||||
"G33d09280105bfe16aaff54dad304739e2e091c9a1e647164caf2394ee513a2e2": "G5e9b6f5ab4339cbfcc62ab4f13f0ddb4c2576573e0f25f032602d11816acd846",
|
||||
"G7d0dc89e17f74a9548c10538e3e2c6e6b99e2c5e77f98699d104f4c06682a432": "Gf67e8bf457336de147cd7e3aa5fd27d3aaa33f057a66452c4a3eae73d73e0ef0"
|
||||
}
|
||||
+499
-587
File diff suppressed because it is too large
Load Diff
+232
-232
File diff suppressed because it is too large
Load Diff
+10
@@ -1,3 +1,4 @@
|
||||
import { ErrorPage } from "#app/pages/ErrorPage";
|
||||
import FolderInfoHeader from "#components/game/folder/FolderInfoHeader";
|
||||
import QuickTooltip from "#components/layout/misc/QuickTooltip";
|
||||
import DifficultyCell from "#components/tables/cells/DifficultyCell";
|
||||
@@ -145,6 +146,15 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
|
||||
const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`;
|
||||
|
||||
if (error?.statusCode === 404) {
|
||||
return (
|
||||
<ErrorPage
|
||||
customMessage={error.description ?? "This folder does not exist."}
|
||||
statusCode={404}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
+85
-35
@@ -1,4 +1,4 @@
|
||||
import AddNewGoalForQuestModal from "#components/targets/AddNewGoalForQuestModal";
|
||||
import GoalBuilder from "#components/targets/GoalBuilder";
|
||||
import DeleteGoalsModal from "#components/targets/DeleteGoalsModal";
|
||||
import GoalSubInfo from "#components/targets/GoalSubInfo";
|
||||
import ApiError from "#components/util/ApiError";
|
||||
@@ -12,13 +12,14 @@ import { type UGPT } from "#types/react";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { CreateGoalSubDataset } from "#util/data";
|
||||
import React, { useContext, useReducer, useState } from "react";
|
||||
import { Button, Col } from "react-bootstrap";
|
||||
import { Button, Col, Modal } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormatGame } from "tachi-common";
|
||||
import { type GoalDocument, FormatGame } from "tachi-common";
|
||||
|
||||
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<GoalDocument | null>(null);
|
||||
const { reloadTargets } = useContext(TargetsContext);
|
||||
const [refresh, refetchGoals] = useReducer((x) => x + 1, 0);
|
||||
|
||||
@@ -37,15 +38,57 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
|
||||
}
|
||||
|
||||
const userMap = new Map([[reqUser.id, reqUser]]);
|
||||
|
||||
const dataset = CreateGoalSubDataset(data, userMap);
|
||||
|
||||
const handleAddGoal = async (rawGoal: { goal: Pick<GoalDocument, "charts" | "criteria" | "name"> }) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
criteria: rawGoal.goal.criteria,
|
||||
charts: rawGoal.goal.charts,
|
||||
}),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setShowAdd(false);
|
||||
refetchGoals();
|
||||
reloadTargets();
|
||||
};
|
||||
|
||||
const handleUpdateGoal = async (
|
||||
oldGoalID: string,
|
||||
rawGoal: { goal: Pick<GoalDocument, "charts" | "criteria" | "name"> },
|
||||
) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/${oldGoalID}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
criteria: rawGoal.goal.criteria,
|
||||
charts: rawGoal.goal.charts,
|
||||
}),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setEditingGoal(null);
|
||||
refetchGoals();
|
||||
reloadTargets();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Col xs={12}>
|
||||
<Button
|
||||
className="mb-4 w-100"
|
||||
onClick={() => setShow(true)}
|
||||
onClick={() => setShowAdd(true)}
|
||||
size="lg"
|
||||
variant="outline-success"
|
||||
>
|
||||
@@ -64,49 +107,56 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
|
||||
<Link to={`/games/${game}/quests`}>Check out {FormatGame(game)}'s Quests</Link>.
|
||||
</div>
|
||||
<Divider />
|
||||
<GoalSubInfo dataset={dataset} game={game} />
|
||||
</Col>
|
||||
{show && (
|
||||
<AddNewGoalForQuestModal
|
||||
<GoalSubInfo
|
||||
dataset={dataset}
|
||||
game={game}
|
||||
noNote
|
||||
onCreate={async (rawGoal) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
criteria: rawGoal.goal.criteria,
|
||||
charts: rawGoal.goal.charts,
|
||||
}),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
refetchGoals();
|
||||
reloadTargets();
|
||||
}}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
onEditGoal={(goal) => setEditingGoal(goal)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* Add goal modal */}
|
||||
<Modal onHide={() => setShowAdd(false)} show={showAdd} size="xl">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Add New Goal</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<GoalBuilder
|
||||
confirmLabel="Add Goal"
|
||||
game={game}
|
||||
onCreate={handleAddGoal}
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
|
||||
{/* Update goal modal */}
|
||||
{editingGoal && (
|
||||
<Modal onHide={() => setEditingGoal(null)} show size="xl">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Update Goal</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<GoalBuilder
|
||||
confirmLabel="Update Goal"
|
||||
existingGoal={editingGoal}
|
||||
game={game}
|
||||
onCreate={(rawGoal) => handleUpdateGoal(editingGoal.goalID, rawGoal)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<DeleteGoalsModal
|
||||
dataset={dataset}
|
||||
onDelete={async (goalID) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/${goalID}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
{ method: "DELETE" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
refetchGoals();
|
||||
|
||||
reloadTargets();
|
||||
}}
|
||||
setShow={setShowDelete}
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
/**
|
||||
* Quest & Questline Editor
|
||||
*
|
||||
* Three-panel layout:
|
||||
* Left — Quest list + "New Quest" inline form
|
||||
* Centre — Selected quest editor (EditableQuest)
|
||||
* Right — Questline composer
|
||||
*
|
||||
* Everything is saved to localStorage under LOCAL_QUEST_KEY / LOCAL_QUESTLINE_KEY.
|
||||
* Export produces separate quests.json and questlines.json files.
|
||||
*/
|
||||
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import AddNewQuestModal from "#components/targets/AddNewQuestModal";
|
||||
import EditableQuest from "#components/targets/quests/editor/EditableQuest";
|
||||
import Divider from "#components/util/Divider";
|
||||
import EditableText from "#components/util/EditableText";
|
||||
import Icon from "#components/util/Icon";
|
||||
import Muted from "#components/util/Muted";
|
||||
import { TachiConfig } from "#lib/config";
|
||||
import { type SetState } from "#types/react";
|
||||
import { type RawQuestDocument } from "#types/tachi";
|
||||
import { type RawQuestDocument, type RawQuestlineDocument } from "#types/tachi";
|
||||
import { ChangeAtPosition, DeleteInPosition } from "#util/misc";
|
||||
import { p, type PrudenceSchema } from "prudence";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { FormatPrError, GetGameGroupConfig } from "tachi-common";
|
||||
import { Alert, Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import {
|
||||
FormatGame,
|
||||
type GameGroup,
|
||||
FormatPrError,
|
||||
GetGameGroupConfig,
|
||||
LEGACY_GameGroupPTToGame,
|
||||
type LEGACY_GPTString,
|
||||
type LEGACY_Playtype,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
// ─── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
||||
const LOCAL_QUEST_KEY = "LOCAL_QUESTS";
|
||||
const LOCAL_QUESTLINE_KEY = "LOCAL_QUESTLINES";
|
||||
|
||||
// ─── Validation schemas ───────────────────────────────────────────────────────
|
||||
|
||||
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
|
||||
json: [
|
||||
{
|
||||
game: p.isIn(TachiConfig.GAME_GROUPS),
|
||||
playtype: (self, parent) => {
|
||||
const gameConfig = GetGameGroupConfig(parent.game as any);
|
||||
const gameConfig = GetGameGroupConfig(parent.game as GameGroup);
|
||||
|
||||
if (!gameConfig.playtypes.includes(self)) {
|
||||
return `Invalid playtype '${self}' for ${parent.game}`;
|
||||
if (!(gameConfig.playtypes as ReadonlyArray<unknown>).includes(self)) {
|
||||
return `Invalid playtype '${String(self)}' for ${String(parent.game)}`;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -38,29 +64,16 @@ const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
|
||||
goal: {
|
||||
name: "string",
|
||||
charts: p.or(
|
||||
{
|
||||
type: p.is("folder"),
|
||||
data: "string",
|
||||
},
|
||||
{
|
||||
type: p.is("multi"),
|
||||
data: ["string"],
|
||||
},
|
||||
{
|
||||
type: p.is("single"),
|
||||
data: "string",
|
||||
},
|
||||
{ type: p.is("folder"), data: "string" },
|
||||
{ type: p.is("multi"), data: ["string"] },
|
||||
{ type: p.is("single"), data: "string" },
|
||||
),
|
||||
criteria: p.or(
|
||||
{
|
||||
mode: p.is("single"),
|
||||
key: "string", // temp
|
||||
value: "number",
|
||||
},
|
||||
{ mode: p.is("single"), key: "string", value: "number" },
|
||||
{
|
||||
mode: p.isIn("absolute", "proportion"),
|
||||
countNum: p.isPositive,
|
||||
key: "string", // temp
|
||||
key: "string",
|
||||
value: "number",
|
||||
},
|
||||
),
|
||||
@@ -74,7 +87,9 @@ const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
|
||||
],
|
||||
};
|
||||
|
||||
function GetLocalQuests(): Array<RawQuestDocument> {
|
||||
// ─── localStorage helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function loadLocalQuests(): Array<RawQuestDocument> {
|
||||
try {
|
||||
const data = window.localStorage.getItem(LOCAL_QUEST_KEY);
|
||||
|
||||
@@ -83,194 +98,632 @@ function GetLocalQuests(): Array<RawQuestDocument> {
|
||||
}
|
||||
|
||||
const json = JSON.parse(data);
|
||||
|
||||
// check that this data is what it seems
|
||||
const err = p({ json }, PR_LOCAL_QUESTS_SCHEMA);
|
||||
|
||||
if (err) {
|
||||
const e = confirm(
|
||||
`Failed to validate your local quests: ${FormatPrError(
|
||||
err,
|
||||
)}. DELETE ALL AND START AGAIN?`,
|
||||
);
|
||||
|
||||
if (e) {
|
||||
if (
|
||||
confirm(
|
||||
`Failed to validate local quests: ${FormatPrError(err)}. Delete all and start again?`,
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
return json as RawQuestDocument[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalQuestlines(): Array<RawQuestlineDocument> {
|
||||
try {
|
||||
const data = window.localStorage.getItem(LOCAL_QUESTLINE_KEY);
|
||||
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(data) as RawQuestlineDocument[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function downloadJson(data: unknown, filename: string) {
|
||||
const href = `data:application/json;charset=UTF-8,${encodeURIComponent(JSON.stringify(data, null, "\t"))}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function QuestEditor() {
|
||||
useSetSubheader(["Developer Utils", "Quest Creator"]);
|
||||
useSetSubheader(["Developer Utils", "Quest & Questline Editor"]);
|
||||
|
||||
const INIT_STATE = useMemo(() => GetLocalQuests(), []);
|
||||
const [quests, setQuests] = useState(INIT_STATE);
|
||||
const [show, setShow] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const initQuests = useMemo(() => loadLocalQuests(), []);
|
||||
const initQuestlines = useMemo(() => loadLocalQuestlines(), []);
|
||||
|
||||
const [quests, setQuests] = useState<Array<RawQuestDocument>>(initQuests);
|
||||
const [questlines, setQuestlines] = useState<Array<RawQuestlineDocument>>(initQuestlines);
|
||||
const [selectedQuestIdx, setSelectedQuestIdx] = useState<number | null>(null);
|
||||
|
||||
const [showImportQuests, setShowImportQuests] = useState(false);
|
||||
const [showImportQuestlines, setShowImportQuestlines] = useState(false);
|
||||
|
||||
// ── Persist to localStorage on every change ────────────────────────────────
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_QUEST_KEY, JSON.stringify(quests));
|
||||
}, [quests]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_QUESTLINE_KEY, JSON.stringify(questlines));
|
||||
}, [questlines]);
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
const selectedQuest = selectedQuestIdx !== null ? quests[selectedQuestIdx] ?? null : null;
|
||||
|
||||
const addQuest = (gptString: LEGACY_GPTString) => {
|
||||
const [game, playtype] = gptString.split(":") as [GameGroup, LEGACY_Playtype];
|
||||
const v3Game: V3Game = LEGACY_GameGroupPTToGame(game, playtype);
|
||||
|
||||
const newQuest: RawQuestDocument = {
|
||||
game: v3Game,
|
||||
name: "Untitled Quest",
|
||||
desc: "Please set a description.",
|
||||
rawQuestData: [],
|
||||
};
|
||||
|
||||
setQuests((prev) => {
|
||||
const updated = [...prev, newQuest];
|
||||
setSelectedQuestIdx(updated.length - 1);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Row className="g-3">
|
||||
<Col xs={12}>
|
||||
<h1>{TachiConfig.NAME} Quest Editor</h1>
|
||||
<Divider />
|
||||
<span>
|
||||
This tool is for creating your own quests and questlines.
|
||||
<br />
|
||||
These can be saved to a <code>.json</code> file, and sent to an admin to be
|
||||
considered for inclusion on the site!
|
||||
</span>
|
||||
<h2 className="mb-1">Quest & Questline Editor</h2>
|
||||
<p className="text-body-secondary small">
|
||||
Build quests and questlines locally, then download the JSON files and submit
|
||||
them as a PR to be included on the site.
|
||||
</p>
|
||||
<Divider />
|
||||
</Col>
|
||||
|
||||
<Col xs={12}>
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
{quests.length > 0 && (
|
||||
<>
|
||||
<a
|
||||
className="btn btn-success me-4"
|
||||
download={`Quests-${Date.now()}.json`}
|
||||
href={`data:application/json;charset=UTF-8,${encodeURIComponent(
|
||||
JSON.stringify(quests),
|
||||
)}`}
|
||||
>
|
||||
Download Quests
|
||||
</a>
|
||||
<div
|
||||
className="btn btn-danger me-4"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to start from scratch?")) {
|
||||
setQuests([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start Again
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="btn btn-info" onClick={() => setShowImport(true)}>
|
||||
Import Quests
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
If you want these quests to be added to the site, make a post about your quests
|
||||
in the discord!
|
||||
{/* ── Left panel: Quest list ─────────────────────────────────────── */}
|
||||
<Col lg={3} xs={12}>
|
||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||
<h5 className="mb-0">Quests</h5>
|
||||
<Badge bg="secondary">{quests.length}</Badge>
|
||||
</div>
|
||||
|
||||
<QuestList
|
||||
onAddQuest={addQuest}
|
||||
onSelect={setSelectedQuestIdx}
|
||||
quests={quests}
|
||||
selectedIdx={selectedQuestIdx}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
</Col>
|
||||
{quests.map((quest, i) => (
|
||||
<Col className="my-4" key={i} lg={6} xs={12}>
|
||||
<EditableQuest
|
||||
onChange={(quest) => {
|
||||
setQuests(ChangeAtPosition(quests, quest, i));
|
||||
}}
|
||||
onDelete={() => {
|
||||
setQuests(DeleteInPosition(quests, i));
|
||||
}}
|
||||
quest={quest}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
<Col className="my-4" lg={6} xs={12}>
|
||||
<div className="w-100 h-100">
|
||||
<div className="d-flex w-100 h-100 justify-content-center align-items-center">
|
||||
<Button onClick={() => setShow(true)} variant="outline-success">
|
||||
<Icon type="plus" /> Add New Quest
|
||||
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{quests.length > 0 && (
|
||||
<Button
|
||||
onClick={() => downloadJson(quests, `quests-${Date.now()}.json`)}
|
||||
size="sm"
|
||||
variant="outline-success"
|
||||
>
|
||||
<Icon type="download" /> Download quests.json
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowImportQuests(true)}
|
||||
size="sm"
|
||||
variant="outline-info"
|
||||
>
|
||||
<Icon type="upload" /> Import quests.json
|
||||
</Button>
|
||||
{quests.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm("Delete all quests and start over?")) {
|
||||
setQuests([]);
|
||||
setSelectedQuestIdx(null);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
Clear All Quests
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<AddNewQuestModal
|
||||
onCreate={(rawQuest) => setQuests([...quests, rawQuest])}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
/>
|
||||
{showImport && (
|
||||
<ImportQuestsModal
|
||||
onChange={(newData) => setQuests(newData)}
|
||||
setShow={setShowImport}
|
||||
show={showImport}
|
||||
|
||||
{/* ── Centre panel: Quest editor ─────────────────────────────────── */}
|
||||
<Col lg={5} xs={12}>
|
||||
{selectedQuest && selectedQuestIdx !== null ? (
|
||||
<EditableQuest
|
||||
onChange={(updated) =>
|
||||
setQuests(ChangeAtPosition(quests, updated, selectedQuestIdx))
|
||||
}
|
||||
onDelete={() => {
|
||||
setQuests(DeleteInPosition(quests, selectedQuestIdx));
|
||||
setSelectedQuestIdx(null);
|
||||
}}
|
||||
quest={selectedQuest}
|
||||
/>
|
||||
) : (
|
||||
<div className="d-flex h-100 align-items-center justify-content-center text-center text-body-secondary border rounded p-4">
|
||||
<div>
|
||||
<Icon type="arrow-left" />
|
||||
<p className="mt-2 small">Select a quest from the list to edit it.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* ── Right panel: Questline composer ───────────────────────────── */}
|
||||
<Col lg={4} xs={12}>
|
||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||
<h5 className="mb-0">Questlines</h5>
|
||||
<Badge bg="secondary">{questlines.length}</Badge>
|
||||
</div>
|
||||
|
||||
<QuestlineComposer
|
||||
onAddQuestline={(ql) => setQuestlines((prev) => [...prev, ql])}
|
||||
onUpdate={(updated, idx) =>
|
||||
setQuestlines(ChangeAtPosition(questlines, updated, idx))
|
||||
}
|
||||
onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))}
|
||||
questlines={questlines}
|
||||
quests={quests}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{questlines.length > 0 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
downloadJson(questlines, `questlines-${Date.now()}.json`)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline-success"
|
||||
>
|
||||
<Icon type="download" /> Download questlines.json
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowImportQuestlines(true)}
|
||||
size="sm"
|
||||
variant="outline-info"
|
||||
>
|
||||
<Icon type="upload" /> Import questlines.json
|
||||
</Button>
|
||||
{questlines.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm("Delete all questlines?")) {
|
||||
setQuestlines([]);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
Clear All Questlines
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* Import modals */}
|
||||
{showImportQuests && (
|
||||
<ImportJsonModal
|
||||
description="quests.json"
|
||||
onHide={() => setShowImportQuests(false)}
|
||||
onImport={(data) => {
|
||||
setQuests(data as RawQuestDocument[]);
|
||||
setSelectedQuestIdx(null);
|
||||
}}
|
||||
schema={PR_LOCAL_QUESTS_SCHEMA}
|
||||
/>
|
||||
)}
|
||||
{showImportQuestlines && (
|
||||
<ImportJsonModal
|
||||
description="questlines.json"
|
||||
onHide={() => setShowImportQuestlines(false)}
|
||||
onImport={(data) => setQuestlines(data as RawQuestlineDocument[])}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportQuestsModal({
|
||||
show,
|
||||
setShow,
|
||||
onChange,
|
||||
// ─── QuestList ────────────────────────────────────────────────────────────────
|
||||
|
||||
function QuestList({
|
||||
quests,
|
||||
selectedIdx,
|
||||
onSelect,
|
||||
onAddQuest,
|
||||
}: {
|
||||
onChange: (data: Array<RawQuestDocument>) => void;
|
||||
setShow: SetState<boolean>;
|
||||
show: boolean;
|
||||
onAddQuest: (gpt: LEGACY_GPTString) => void;
|
||||
onSelect: (idx: number) => void;
|
||||
quests: Array<RawQuestDocument>;
|
||||
selectedIdx: number | null;
|
||||
}) {
|
||||
const [gpt, setGpt] = useState<LEGACY_GPTString | null>(null);
|
||||
|
||||
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
|
||||
(gameGroup) => {
|
||||
const config = GetGameGroupConfig(gameGroup);
|
||||
|
||||
return config.playtypes.map((pt) => ({
|
||||
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
|
||||
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-1 mb-3">
|
||||
{quests.length === 0 && (
|
||||
<span className="text-body-secondary small">No quests yet. Add one below.</span>
|
||||
)}
|
||||
{quests.map((quest, i) => (
|
||||
<button
|
||||
className={`btn btn-sm text-start text-truncate ${
|
||||
selectedIdx === i
|
||||
? "btn-primary"
|
||||
: "btn-outline-secondary"
|
||||
}`}
|
||||
key={i}
|
||||
onClick={() => onSelect(i)}
|
||||
title={quest.name}
|
||||
type="button"
|
||||
>
|
||||
<span className="small me-2 text-body-secondary">
|
||||
{FormatGame(quest.game)}
|
||||
</span>
|
||||
{quest.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Inline new-quest form */}
|
||||
<div className="mt-2 d-flex gap-2 align-items-center">
|
||||
<Form.Select
|
||||
onChange={(e) => setGpt(e.target.value as LEGACY_GPTString)}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
value={gpt ?? ""}
|
||||
>
|
||||
<option value="">Game…</option>
|
||||
{allGpts.map((g) => (
|
||||
<option key={g.value} value={g.value}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Button
|
||||
disabled={gpt === null}
|
||||
onClick={() => {
|
||||
if (gpt) {
|
||||
onAddQuest(gpt);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
<Icon type="plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── QuestlineComposer ────────────────────────────────────────────────────────
|
||||
|
||||
function QuestlineComposer({
|
||||
questlines,
|
||||
quests,
|
||||
onAddQuestline,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
onAddQuestline: (ql: RawQuestlineDocument) => void;
|
||||
onDelete: (idx: number) => void;
|
||||
onUpdate: (updated: RawQuestlineDocument, idx: number) => void;
|
||||
questlines: Array<RawQuestlineDocument>;
|
||||
quests: Array<RawQuestDocument>;
|
||||
}) {
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newGame, setNewGame] = useState<LEGACY_GPTString | "">("");
|
||||
|
||||
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
|
||||
(gameGroup) => {
|
||||
const config = GetGameGroupConfig(gameGroup);
|
||||
|
||||
return config.playtypes.map((pt) => ({
|
||||
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
|
||||
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-3 mb-3">
|
||||
{questlines.length === 0 && (
|
||||
<span className="text-body-secondary small">No questlines yet.</span>
|
||||
)}
|
||||
|
||||
{questlines.map((ql, idx) => (
|
||||
<QuestlineCard
|
||||
key={idx}
|
||||
onDelete={() => onDelete(idx)}
|
||||
onUpdate={(updated) => onUpdate(updated, idx)}
|
||||
quests={quests}
|
||||
questline={ql}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New questline inline form */}
|
||||
<div className="border rounded p-2">
|
||||
<p className="small fw-semibold mb-2">New Questline</p>
|
||||
<Form.Control
|
||||
className="mb-2"
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Questline name…"
|
||||
size="sm"
|
||||
value={newName}
|
||||
/>
|
||||
<div className="d-flex gap-2">
|
||||
<Form.Select
|
||||
onChange={(e) => setNewGame(e.target.value as LEGACY_GPTString)}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
value={newGame}
|
||||
>
|
||||
<option value="">Game…</option>
|
||||
{allGpts.map((g) => (
|
||||
<option key={g.value} value={g.value}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Button
|
||||
disabled={!newName.trim() || !newGame}
|
||||
onClick={() => {
|
||||
if (!newName.trim() || !newGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [gameGroup, playtype] = newGame.split(":") as [
|
||||
GameGroup,
|
||||
LEGACY_Playtype,
|
||||
];
|
||||
|
||||
const slug = newName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/gu, "-")
|
||||
.replace(/[^a-z0-9-]/gu, "");
|
||||
|
||||
onAddQuestline({
|
||||
questlineID: `${slug}-${Date.now()}`,
|
||||
name: newName.trim(),
|
||||
desc: "",
|
||||
game: gameGroup,
|
||||
playtype,
|
||||
quests: [],
|
||||
});
|
||||
|
||||
setNewName("");
|
||||
setNewGame("");
|
||||
}}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
<Icon type="plus" /> Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestlineCard({
|
||||
questline,
|
||||
quests,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete: () => void;
|
||||
onUpdate: (updated: RawQuestlineDocument) => void;
|
||||
questline: RawQuestlineDocument;
|
||||
quests: Array<RawQuestDocument>;
|
||||
}) {
|
||||
const availableQuests = quests.filter(
|
||||
(q) => !questline.quests.includes(q.name),
|
||||
);
|
||||
|
||||
const addQuestByName = (name: string) => {
|
||||
if (!questline.quests.includes(name)) {
|
||||
onUpdate({ ...questline, quests: [...questline.quests, name] });
|
||||
}
|
||||
};
|
||||
|
||||
const removeQuest = (name: string) => {
|
||||
onUpdate({ ...questline, quests: questline.quests.filter((q) => q !== name) });
|
||||
};
|
||||
|
||||
const moveQuest = (idx: number, direction: -1 | 1) => {
|
||||
const arr = [...questline.quests];
|
||||
const target = idx + direction;
|
||||
|
||||
if (target < 0 || target >= arr.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
[arr[idx], arr[target]] = [arr[target]!, arr[idx]!];
|
||||
onUpdate({ ...questline, quests: arr });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded p-2">
|
||||
<div className="d-flex align-items-start gap-2 mb-1">
|
||||
<div className="flex-grow-1">
|
||||
<EditableText
|
||||
as="span"
|
||||
authorised
|
||||
initialText={questline.name}
|
||||
onSubmit={(name) => onUpdate({ ...questline, name })}
|
||||
placeholderText="Questline name"
|
||||
/>
|
||||
<EditableText
|
||||
authorised
|
||||
initialText={questline.desc}
|
||||
onSubmit={(desc) => onUpdate({ ...questline, desc })}
|
||||
placeholderText="Description…"
|
||||
/>
|
||||
<span className="text-body-secondary small">{questline.game} / {questline.playtype}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm py-0"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete questline "${questline.name}"?`)) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon type="trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ordered quest list */}
|
||||
{questline.quests.length === 0 ? (
|
||||
<span className="text-body-secondary small">No quests added yet.</span>
|
||||
) : (
|
||||
<div className="d-flex flex-column gap-1 mb-2">
|
||||
{questline.quests.map((questName, i) => (
|
||||
<div className="d-flex align-items-center gap-1" key={i}>
|
||||
<span className="text-body-secondary small me-1">{i + 1}.</span>
|
||||
<span className="flex-grow-1 small text-truncate">{questName}</span>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={i === 0}
|
||||
onClick={() => moveQuest(i, -1)}
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-up" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={i === questline.quests.length - 1}
|
||||
onClick={() => moveQuest(i, 1)}
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-down" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm py-0"
|
||||
onClick={() => removeQuest(questName)}
|
||||
type="button"
|
||||
>
|
||||
<Icon type="times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add quest from available */}
|
||||
{availableQuests.length > 0 && (
|
||||
<Form.Select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addQuestByName(e.target.value);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<option value="">Add quest…</option>
|
||||
{availableQuests.map((q, i) => (
|
||||
<option key={i} value={q.name}>
|
||||
{q.name} ({FormatGame(q.game)})
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ImportJsonModal ──────────────────────────────────────────────────────────
|
||||
|
||||
function ImportJsonModal({
|
||||
description,
|
||||
onHide,
|
||||
onImport,
|
||||
schema,
|
||||
}: {
|
||||
description: string;
|
||||
onHide: () => void;
|
||||
onImport: (data: unknown[]) => void;
|
||||
schema?: PrudenceSchema;
|
||||
}) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Modal onHide={() => setShow(false)} show={show} size="xl">
|
||||
<Modal onHide={onHide} show size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Import <code>quests.json</code>
|
||||
</Modal.Title>
|
||||
<Modal.Title>Import {description}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<Alert variant="warning">
|
||||
Uploading existing quests will replace whatever you're currently working
|
||||
on! Make sure you've saved your existing work!
|
||||
</Alert>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
Upload <code>quests.json</code> File
|
||||
</Form.Label>
|
||||
<input
|
||||
accept="application/json"
|
||||
className="form-control"
|
||||
multiple={false}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
const file: File = e.target.files![0];
|
||||
<Alert variant="warning">
|
||||
This will replace your current {description} data. Make sure you've exported
|
||||
first!
|
||||
</Alert>
|
||||
<Form.Group>
|
||||
<Form.Label>Upload {description}</Form.Label>
|
||||
<input
|
||||
accept="application/json"
|
||||
className="form-control"
|
||||
multiple={false}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
const contents = JSON.parse(await file.text());
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const err = p({ json: contents }, PR_LOCAL_QUESTS_SCHEMA);
|
||||
const contents = JSON.parse(await file.text()) as unknown[];
|
||||
|
||||
if (err) {
|
||||
throw new Error(FormatPrError(err));
|
||||
}
|
||||
if (schema) {
|
||||
const prErr = p({ json: contents }, schema);
|
||||
|
||||
onChange(contents as RawQuestDocument[]);
|
||||
|
||||
setShow(false);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setErr(err.message);
|
||||
if (prErr) {
|
||||
throw new Error(FormatPrError(prErr));
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
{err && (
|
||||
<Col xs={12}>
|
||||
<span className="text-danger">Invalid file: {err}</span>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
}
|
||||
|
||||
onImport(contents);
|
||||
onHide();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</Form.Group>
|
||||
{err && <p className="text-danger small mt-2">Invalid file: {err}</p>}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -36,22 +36,24 @@ export default function QuestlinePage({ game }: GamePT) {
|
||||
}
|
||||
|
||||
const goalMap = CreateGoalMap(data.goals);
|
||||
|
||||
const questMap = CreateQuestMap(data.quests);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<Link to={`/games/${game}/quests`}>Go back to all questlines...</Link>
|
||||
<Link to={`/games/${game}/quests`}>← Back to all questlines</Link>
|
||||
<Divider />
|
||||
{/* Summary card with timeline — the Questline component renders the
|
||||
vertical timeline nodes when a quests map is provided. */}
|
||||
<Questline questline={data.questline} quests={questMap} />
|
||||
<Divider />
|
||||
</Col>
|
||||
|
||||
{/* Full quest cards below, anchored so timeline links scroll to them */}
|
||||
{data.questline.quests.map((questID) => {
|
||||
const quest = questMap.get(questID);
|
||||
|
||||
if (!quest) {
|
||||
// shouldn't happen, but paste over it.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* GoalBuilder — a focused, step-based UI for creating or updating a goal.
|
||||
*
|
||||
* Step 1 – Chart target: card-toggle buttons (Single / Multiple / Folder) with
|
||||
* an AsyncSelect beneath the chosen type.
|
||||
* Step 2 – Criteria: pill-button metric selector + inline value picker.
|
||||
*
|
||||
* Live goal-name preview is fetched from the server with a 400 ms debounce.
|
||||
*
|
||||
* When `existingGoal` is provided the builder pre-fills from it and shows a
|
||||
* diff summary in the preview header.
|
||||
*/
|
||||
|
||||
import AsyncSelect from "#components/util/AsyncSelect";
|
||||
import Divider from "#components/util/Divider";
|
||||
import Icon from "#components/util/Icon";
|
||||
import { type SongChartsSearch } from "#types/api-returns";
|
||||
import { type GamePT, type SetState } from "#types/react";
|
||||
import { type RawQuestGoal } from "#types/tachi";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { clamp, UppercaseFirst } from "#util/misc";
|
||||
import { StrSOV } from "#util/sorts";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Badge, Button, Col, Form, InputGroup, Row } from "react-bootstrap";
|
||||
import { type GroupBase, type OptionsOrGroups } from "react-select";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type FolderDocument,
|
||||
FormatChart,
|
||||
GetGameConfig,
|
||||
GetScoreMetricConf,
|
||||
GetScoreMetrics,
|
||||
type GoalDocument,
|
||||
type SongDocument,
|
||||
} from "tachi-common";
|
||||
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
// ─── Public interface ─────────────────────────────────────────────────────────
|
||||
|
||||
interface GoalBuilderProps extends GamePT {
|
||||
/**
|
||||
* Called when the user confirms the goal.
|
||||
*
|
||||
* Receives the `RawQuestGoal` so callers can either add-goal directly or
|
||||
* attach a note for the quest-editor use-case.
|
||||
*/
|
||||
onCreate: (rawGoal: RawQuestGoal) => void;
|
||||
|
||||
/** If set, shows a note input (useful when adding a goal inside a quest). */
|
||||
showNote?: boolean;
|
||||
|
||||
/**
|
||||
* When provided the builder pre-fills from this goal's `charts` and
|
||||
* `criteria` so the user can modify and "update" it.
|
||||
*/
|
||||
existingGoal?: GoalDocument;
|
||||
|
||||
/** Label for the confirm button. Defaults to "Add Goal". */
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GoalBuilder({
|
||||
game,
|
||||
onCreate,
|
||||
showNote = false,
|
||||
existingGoal,
|
||||
confirmLabel = "Add Goal",
|
||||
}: GoalBuilderProps) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const defaultEnum = gameConfig.preferredDefaultEnum;
|
||||
const enumConf = GetScoreMetricConf(gameConfig, defaultEnum) as ConfEnumScoreMetric<string>;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const [charts, setCharts] = useState<GoalDocument["charts"]>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
existingGoal?.charts ?? ({ type: "single", data: null } as any),
|
||||
);
|
||||
|
||||
const [criteria, setCriteria] = useState<GoalDocument["criteria"]>(
|
||||
existingGoal?.criteria ?? {
|
||||
mode: "single",
|
||||
key: defaultEnum,
|
||||
value: enumConf.values.indexOf(enumConf.minimumRelevantValue),
|
||||
},
|
||||
);
|
||||
|
||||
const [note, setNote] = useState("");
|
||||
const [goalName, setGoalName] = useState(existingGoal?.name ?? "...");
|
||||
const [goalErr, setGoalErr] = useState<string | null>(null);
|
||||
|
||||
// ── Sync: force single mode when chart type is single ─────────────────────
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (charts.type === "single") {
|
||||
setCriteria((prev) => ({ mode: "single", key: prev.key, value: prev.value }));
|
||||
}
|
||||
}, [charts.type]);
|
||||
|
||||
// ── Live preview (debounced 400 ms) ───────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if ("data" in charts && charts.data === null) {
|
||||
setGoalName("...");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = window.setTimeout(async () => {
|
||||
const res = await APIFetchV1<string>(`/games/${game}/targets/goals/format`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ criteria, charts }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setGoalName(res.body);
|
||||
setGoalErr(null);
|
||||
} else {
|
||||
const match = /^Invalid goal: (.*)/u.exec(res.description) as
|
||||
| [string, string]
|
||||
| null;
|
||||
|
||||
if (match) {
|
||||
setGoalName("...");
|
||||
setGoalErr(match[1]);
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(id);
|
||||
}, [charts, criteria, game]);
|
||||
|
||||
// ── Computed helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const isReady =
|
||||
goalName !== "..." &&
|
||||
!goalErr &&
|
||||
!("data" in charts && charts.data === null) &&
|
||||
!(criteria.mode === "absolute" && criteria.countNum <= 1);
|
||||
|
||||
const isDiffMode = !!existingGoal;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="goal-builder">
|
||||
{/* ── Preview header ─────────────────────────────────────────────── */}
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-body-secondary small mb-1">
|
||||
{isDiffMode ? "Updating goal to:" : "Goal preview:"}
|
||||
</p>
|
||||
<h4 className={`display-6 mb-0 ${goalName === "..." ? "text-body-secondary" : ""}`}>
|
||||
{goalName}
|
||||
</h4>
|
||||
{isDiffMode && existingGoal.name !== goalName && goalName !== "..." && (
|
||||
<p className="text-body-secondary small mt-1">
|
||||
<s>{existingGoal.name}</s>
|
||||
</p>
|
||||
)}
|
||||
{goalErr && <p className="text-danger small mt-1">{goalErr}</p>}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Step 1: Chart target ───────────────────────────────────────── */}
|
||||
<h6 className="mb-3 fw-semibold">Step 1 — Chart Target</h6>
|
||||
<ChartTargetPicker charts={charts} game={game} onChange={setCharts} />
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Step 2: Criteria ──────────────────────────────────────────── */}
|
||||
<h6 className="mb-3 fw-semibold">Step 2 — Success Criteria</h6>
|
||||
<CriteriaPicker
|
||||
charts={charts}
|
||||
criteria={criteria}
|
||||
game={game}
|
||||
setCriteria={setCriteria}
|
||||
/>
|
||||
|
||||
{/* ── Optional note ─────────────────────────────────────────────── */}
|
||||
{showNote && (
|
||||
<>
|
||||
<Divider />
|
||||
<InputGroup>
|
||||
<InputGroup.Text>Note (optional)</InputGroup.Text>
|
||||
<Form.Control
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Is this goal particularly noteworthy in this quest?"
|
||||
value={note}
|
||||
/>
|
||||
</InputGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Confirm ───────────────────────────────────────────────────── */}
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button
|
||||
disabled={!isReady}
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1<string>(
|
||||
`/games/${game}/targets/goals/format`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ criteria, charts }),
|
||||
},
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
onCreate({
|
||||
note: note === "" ? undefined : note,
|
||||
goal: { name: res.body, charts, criteria },
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant={isDiffMode ? "warning" : "primary"}
|
||||
>
|
||||
<Icon type={isDiffMode ? "pencil" : "plus"} /> {confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ChartTargetPicker ────────────────────────────────────────────────────────
|
||||
|
||||
function ChartTargetPicker({
|
||||
charts,
|
||||
game,
|
||||
onChange,
|
||||
}: {
|
||||
charts: GoalDocument["charts"];
|
||||
onChange: (charts: GoalDocument["charts"]) => void;
|
||||
} & GamePT) {
|
||||
const [type, setType] = useState<GoalDocument["charts"]["type"]>(charts.type);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [data, setData] = useState<any>("data" in charts ? charts.data : null);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => onChange({ type, data }), [type, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setData(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange({ type, data: null as any });
|
||||
// Intentionally only react to type changes to reset data on type switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type]);
|
||||
|
||||
const types: Array<{ icon: string; id: GoalDocument["charts"]["type"]; label: string }> = [
|
||||
{ icon: "music", id: "single", label: "Single Chart" },
|
||||
{ icon: "layer-group", id: "multi", label: "Multiple Charts" },
|
||||
{ icon: "folder-open", id: "folder", label: "Folder" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Card-toggle buttons */}
|
||||
<Row className="g-2 mb-3">
|
||||
{types.map((t) => (
|
||||
<Col key={t.id} xs={4}>
|
||||
<div
|
||||
className={`border rounded p-3 text-center cursor-pointer user-select-none ${
|
||||
type === t.id
|
||||
? "border-primary bg-primary bg-opacity-10 text-primary"
|
||||
: "border-secondary text-body-secondary"
|
||||
}`}
|
||||
onClick={() => setType(t.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="mb-1">
|
||||
<Icon type={t.icon} />
|
||||
</div>
|
||||
<small className="fw-semibold">{t.label}</small>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Async search */}
|
||||
{type === "folder" ? (
|
||||
<FolderSelect game={game} initialValue={data} onChange={setData} />
|
||||
) : type === "single" ? (
|
||||
<ChartSelect game={game} initialValue={data} onChange={setData} />
|
||||
) : (
|
||||
<ChartSelect game={game} initialValue={data} multi onChange={setData} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CriteriaPicker ───────────────────────────────────────────────────────────
|
||||
|
||||
function CriteriaPicker({
|
||||
criteria,
|
||||
charts,
|
||||
setCriteria,
|
||||
game,
|
||||
}: {
|
||||
charts: GoalDocument["charts"];
|
||||
criteria: GoalDocument["criteria"];
|
||||
setCriteria: SetState<GoalDocument["criteria"]>;
|
||||
} & GamePT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const availableMetrics = GetScoreMetrics(gameConfig, ["ENUM", "DECIMAL", "INTEGER"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Metric pills */}
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
{availableMetrics.map((metricKey) => (
|
||||
<button
|
||||
className={`btn btn-sm ${
|
||||
criteria.key === metricKey ? "btn-primary" : "btn-outline-secondary"
|
||||
}`}
|
||||
key={metricKey}
|
||||
onClick={() => {
|
||||
const conf = GetScoreMetricConf(gameConfig, metricKey);
|
||||
const defaultValue =
|
||||
conf?.type === "ENUM"
|
||||
? conf.values.indexOf(conf.minimumRelevantValue)
|
||||
: 0;
|
||||
|
||||
setCriteria({
|
||||
...criteria,
|
||||
key: metricKey,
|
||||
value: defaultValue,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{UppercaseFirst(metricKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value row */}
|
||||
<div className="d-flex align-items-center flex-wrap gap-2 mb-2">
|
||||
<Badge bg="secondary" className="py-2 px-3">
|
||||
{UppercaseFirst(criteria.key)}
|
||||
</Badge>
|
||||
<span className="text-body-secondary">≥</span>
|
||||
<CriteriaValuePicker
|
||||
criteria={criteria}
|
||||
game={game}
|
||||
onChange={(value) => setCriteria({ ...criteria, value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode picker (absolute / proportion) for multi/folder */}
|
||||
{charts.type !== "single" && (
|
||||
<>
|
||||
<Divider />
|
||||
<CriteriaModePicker
|
||||
charts={charts}
|
||||
criteria={criteria}
|
||||
game={game}
|
||||
onChange={(mode, countNum) => {
|
||||
if (mode === "single") {
|
||||
setCriteria({ mode: "single", key: criteria.key, value: criteria.value });
|
||||
} else {
|
||||
setCriteria({ ...criteria, countNum: countNum ?? 0, mode });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CriteriaValuePicker ─────────────────────────────────────────────────────
|
||||
|
||||
function CriteriaValuePicker({
|
||||
criteria,
|
||||
game,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: GoalDocument["criteria"];
|
||||
onChange: (value: number) => void;
|
||||
} & GamePT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const conf = GetScoreMetricConf(gameConfig, criteria.key);
|
||||
|
||||
if (!conf) {
|
||||
return <span className="text-danger small">Unknown metric: {criteria.key}</span>;
|
||||
}
|
||||
|
||||
switch (conf.type) {
|
||||
case "NULLABLE_GRAPH":
|
||||
case "GRAPH":
|
||||
return <span className="text-body-secondary small">Cannot set goals for graph metrics.</span>;
|
||||
|
||||
case "DECIMAL":
|
||||
case "INTEGER":
|
||||
return (
|
||||
<Form.Control
|
||||
min={0}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ width: "120px" }}
|
||||
type="number"
|
||||
value={criteria.value}
|
||||
/>
|
||||
);
|
||||
|
||||
case "ENUM":
|
||||
return (
|
||||
<Form.Select
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ width: "auto" }}
|
||||
value={criteria.value.toString()}
|
||||
>
|
||||
{conf.values
|
||||
.slice(conf.values.indexOf(conf.minimumRelevantValue))
|
||||
.map((label, i) => {
|
||||
const idx = i + conf.values.indexOf(conf.minimumRelevantValue);
|
||||
return (
|
||||
<option key={idx} value={idx}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Form.Select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CriteriaModePicker ───────────────────────────────────────────────────────
|
||||
|
||||
function CriteriaModePicker({
|
||||
criteria,
|
||||
onChange,
|
||||
charts,
|
||||
game: _game,
|
||||
}: {
|
||||
charts: GoalDocument["charts"];
|
||||
criteria: GoalDocument["criteria"];
|
||||
onChange: (mode: GoalDocument["criteria"]["mode"], countNum?: number) => void;
|
||||
} & GamePT) {
|
||||
const [absCount, setAbsCount] = useState(
|
||||
criteria.mode === "absolute" ? criteria.countNum : 10,
|
||||
);
|
||||
const [perCount, setPerCount] = useState(
|
||||
criteria.mode === "proportion" ? criteria.countNum * 100 : 10,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (criteria.mode === "proportion") {
|
||||
onChange("proportion", perCount / 100);
|
||||
} else if (criteria.mode === "absolute") {
|
||||
onChange("absolute", absCount);
|
||||
}
|
||||
// onChange is intentionally excluded — it's a callback prop that changes every render
|
||||
// and including it would cause infinite loops. absCount/perCount drive the update.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [absCount, perCount]);
|
||||
|
||||
if ("data" in charts && charts.data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluralTarget = charts.type === "multi" ? "of these charts" : "charts in this folder";
|
||||
const anyTarget = charts.type === "multi" ? "any of these charts" : "any chart in this folder";
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-2">
|
||||
<Form.Check
|
||||
checked={criteria.mode === "single"}
|
||||
id="mode-single"
|
||||
label={`On ${anyTarget}`}
|
||||
onChange={() => onChange("single")}
|
||||
type="radio"
|
||||
/>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Check
|
||||
checked={criteria.mode === "absolute"}
|
||||
id="mode-absolute"
|
||||
label="On"
|
||||
onChange={() => onChange("absolute", absCount)}
|
||||
type="radio"
|
||||
/>
|
||||
<Form.Control
|
||||
min={2}
|
||||
onChange={(e) => setAbsCount(Number(e.target.value))}
|
||||
style={{ width: "80px" }}
|
||||
type="number"
|
||||
value={absCount}
|
||||
/>
|
||||
<span>{pluralTarget}</span>
|
||||
</div>
|
||||
{charts.type !== "multi" && (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Check
|
||||
checked={criteria.mode === "proportion"}
|
||||
id="mode-proportion"
|
||||
label="On"
|
||||
onChange={() => onChange("proportion", perCount / 100)}
|
||||
type="radio"
|
||||
/>
|
||||
<Form.Control
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(e) => setPerCount(clamp(Number(e.target.value), 0, 100))}
|
||||
style={{ width: "80px" }}
|
||||
type="number"
|
||||
value={perCount}
|
||||
/>
|
||||
<span>% of {pluralTarget}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FolderSelect ─────────────────────────────────────────────────────────────
|
||||
|
||||
function FolderSelect({
|
||||
game,
|
||||
onChange,
|
||||
initialValue,
|
||||
}: { initialValue?: string | null; onChange: (data: string) => void } & GamePT) {
|
||||
let lastTimeout: number | null = null;
|
||||
|
||||
const loadOptions = (
|
||||
input: string,
|
||||
cb: (options: OptionsOrGroups<unknown, GroupBase<unknown>>) => void,
|
||||
) => {
|
||||
if (lastTimeout !== null) {
|
||||
clearTimeout(lastTimeout);
|
||||
}
|
||||
|
||||
lastTimeout = window.setTimeout(async () => {
|
||||
const res = await APIFetchV1<Array<FolderDocument>>(
|
||||
`/games/${game}/folders?search=${encodeURIComponent(input)}`,
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
const options = res.body
|
||||
.map((e) => ({ value: e.folderID, label: e.title }))
|
||||
.sort(StrSOV((x) => x.label));
|
||||
|
||||
cb(options);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
defaultValue={initialValue ? { value: initialValue, label: initialValue } : undefined}
|
||||
loadOptions={loadOptions}
|
||||
// @ts-expect-error react-select types are unhelpful here
|
||||
onChange={(data) => onChange(data.value)}
|
||||
placeholder="Search for a folder..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ChartSelect ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ChartSelect({
|
||||
game,
|
||||
multi = false,
|
||||
onChange,
|
||||
initialValue,
|
||||
}: {
|
||||
initialValue?: string | string[] | null;
|
||||
multi?: boolean;
|
||||
onChange: (data: string | string[]) => void;
|
||||
} & GamePT) {
|
||||
let lastTimeout: number | null = null;
|
||||
|
||||
const loadOptions = (
|
||||
input: string,
|
||||
cb: (options: OptionsOrGroups<unknown, GroupBase<unknown>>) => void,
|
||||
) => {
|
||||
if (lastTimeout !== null) {
|
||||
clearTimeout(lastTimeout);
|
||||
}
|
||||
|
||||
lastTimeout = window.setTimeout(async () => {
|
||||
const [res, res2] = await Promise.all([
|
||||
APIFetchV1<SongChartsSearch>(
|
||||
`/games/${game}/charts?search=${encodeURIComponent(input)}`,
|
||||
),
|
||||
APIFetchV1<{
|
||||
charts: Record<string, Array<{ chart: ChartDocument; song: SongDocument }>>;
|
||||
}>(`/search/chart-hash?search=${encodeURIComponent(input)}`),
|
||||
]);
|
||||
|
||||
if (!res.success || !res2.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
const hashCharts = res2.body.charts[game] ?? [];
|
||||
const options = [...res.body.charts, ...hashCharts.map((e) => e.chart)]
|
||||
.map((e) => ({ value: e.chartID, label: FormatChart(e) }))
|
||||
.sort(StrSOV((x) => x.label));
|
||||
|
||||
cb(options);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const defaultValue = initialValue
|
||||
? Array.isArray(initialValue)
|
||||
? initialValue.map((v) => ({ value: v, label: v }))
|
||||
: { value: initialValue, label: initialValue }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
defaultValue={defaultValue}
|
||||
isMulti={multi}
|
||||
loadOptions={loadOptions}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange={(data: any) =>
|
||||
onChange(Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value)
|
||||
}
|
||||
placeholder="Search for a chart..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,15 @@ import { type GoalSubDataset } from "#types/tables";
|
||||
import { NumericSOV } from "#util/sorts";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Col } from "react-bootstrap";
|
||||
import { type GoalDocument } from "tachi-common";
|
||||
|
||||
import { InnerQuestSectionGoal } from "./quests/Quest";
|
||||
|
||||
export default function GoalSubInfo({ dataset }: { dataset: GoalSubDataset } & GamePT) {
|
||||
export default function GoalSubInfo({
|
||||
dataset,
|
||||
game,
|
||||
onEditGoal,
|
||||
}: { dataset: GoalSubDataset; onEditGoal?: (goal: GoalDocument) => void } & GamePT) {
|
||||
const [show, setShow] = useState<"achieved" | "all" | "unachieved">("all");
|
||||
|
||||
const { directGoals, folderGoals } = useMemo(() => {
|
||||
@@ -73,6 +78,11 @@ export default function GoalSubInfo({ dataset }: { dataset: GoalSubDataset } & G
|
||||
<InnerQuestSectionGoal
|
||||
dependencies={e.__related.parentQuests.map((e) => e.name)}
|
||||
goal={e.__related.goal}
|
||||
onEdit={
|
||||
e.wasAssignedStandalone && onEditGoal
|
||||
? () => onEditGoal(e.__related.goal)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -86,6 +96,11 @@ export default function GoalSubInfo({ dataset }: { dataset: GoalSubDataset } & G
|
||||
<InnerQuestSectionGoal
|
||||
dependencies={e.__related.parentQuests.map((e) => e.name)}
|
||||
goal={e.__related.goal}
|
||||
onEdit={
|
||||
e.wasAssignedStandalone && onEditGoal
|
||||
? () => onEditGoal(e.__related.goal)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { TargetsContext } from "#context/TargetsContext";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { GetGoalIDsFromQuest } from "#util/data";
|
||||
import { FormatTime } from "#util/time";
|
||||
import React, { useContext } from "react";
|
||||
import { Button, Col } from "react-bootstrap";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Badge, Button, Collapse, ProgressBar } from "react-bootstrap";
|
||||
import { type QuestDocument, type QuestlineDocument } from "tachi-common";
|
||||
|
||||
export default function Questline({
|
||||
@@ -21,146 +21,276 @@ export default function Questline({
|
||||
quests?: Map<string, QuestDocument>;
|
||||
}) {
|
||||
const v3Game = questline.game;
|
||||
const { questSubs } = useContext(TargetsContext);
|
||||
|
||||
const totalQuests = questline.quests.length;
|
||||
const achievedQuests = questline.quests.filter((id) => questSubs.get(id)?.achieved).length;
|
||||
const progressPct = totalQuests > 0 ? Math.round((achievedQuests / totalQuests) * 100) : 0;
|
||||
const anySubscribed = questline.quests.some((id) => questSubs.has(id));
|
||||
|
||||
return (
|
||||
<Card
|
||||
header={
|
||||
<div>
|
||||
<h4>{questline.name}</h4>
|
||||
<Muted>{questline.quests.length} Quests</Muted>
|
||||
<div className="w-100">
|
||||
<div className="d-flex align-items-start justify-content-between gap-3">
|
||||
<div>
|
||||
<h4 className="mb-1">{questline.name}</h4>
|
||||
<Muted>{totalQuests} {totalQuests === 1 ? "Quest" : "Quests"}</Muted>
|
||||
</div>
|
||||
{!quests && (
|
||||
<div className="flex-shrink-0">
|
||||
<LinkButton
|
||||
size="sm"
|
||||
to={`/games/${v3Game}/questlines/${questline.questlineID}`}
|
||||
>
|
||||
View All Quests
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 mb-0 text-body-secondary small">{questline.desc}</p>
|
||||
|
||||
{/* Overall progress bar — shown when the user has subscribed to any quest */}
|
||||
{anySubscribed && (
|
||||
<div className="mt-3">
|
||||
{achievedQuests === totalQuests && totalQuests > 0 ? (
|
||||
<Badge bg="success" className="py-2 px-3">
|
||||
<Icon type="check" /> Questline Complete!
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="d-flex justify-content-between small text-body-secondary mb-1">
|
||||
<span>Questline Progress</span>
|
||||
<span>
|
||||
{achievedQuests} / {totalQuests} quests complete
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
className="rounded-pill"
|
||||
now={progressPct}
|
||||
striped={progressPct > 0}
|
||||
style={{ height: "10px" }}
|
||||
variant="primary"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{questline.desc}
|
||||
<Divider />
|
||||
|
||||
{/* Timeline — shown on questline detail page when quests map is provided */}
|
||||
{quests && (
|
||||
<>
|
||||
<Col className="offset-lg-3" lg={6} xs={12}>
|
||||
{questline.quests.map((questID) => {
|
||||
const quest = quests.get(questID);
|
||||
<div className="questline-timeline ps-2">
|
||||
{questline.quests.map((questID, idx) => {
|
||||
const quest = quests.get(questID);
|
||||
|
||||
// shouldn't happen. hide it.
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex align-items-center my-4"
|
||||
key={questID}
|
||||
style={{
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
<InnerQuestInfo quest={quest} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Col>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!quests && (
|
||||
<div className="d-flex w-100 justify-content-end">
|
||||
<LinkButton to={`/games/${v3Game}/questlines/${questline.questlineID}`}>
|
||||
View Quests
|
||||
</LinkButton>
|
||||
return (
|
||||
<QuestTimelineNode
|
||||
isLast={idx === questline.quests.length - 1}
|
||||
key={questID}
|
||||
number={idx + 1}
|
||||
quest={quest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function InnerQuestInfo({ quest }: { quest: QuestDocument }) {
|
||||
function QuestTimelineNode({
|
||||
quest,
|
||||
number,
|
||||
isLast,
|
||||
}: {
|
||||
isLast: boolean;
|
||||
number: number;
|
||||
quest: QuestDocument;
|
||||
}) {
|
||||
const { questSubs } = useContext(TargetsContext);
|
||||
const questSub = questSubs.get(quest.questID);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isAchieved = questSub?.achieved ?? false;
|
||||
const goalTotal = GetGoalIDsFromQuest(quest).length;
|
||||
|
||||
const nodeColour = isAchieved
|
||||
? "success"
|
||||
: questSub && questSub.progress > 0
|
||||
? "warning"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<div className={`d-flex gap-3 ${isLast ? "" : "mb-0"}`}>
|
||||
{/* Left: connector line + node circle */}
|
||||
<div className="d-flex flex-column align-items-center" style={{ width: "32px" }}>
|
||||
<div
|
||||
className={`rounded-circle d-flex align-items-center justify-content-center fw-bold text-white bg-${nodeColour}`}
|
||||
style={{ width: "32px", height: "32px", minWidth: "32px", fontSize: "0.8rem" }}
|
||||
>
|
||||
{isAchieved ? <Icon type="check" /> : number}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div
|
||||
className="flex-grow-1 mt-1"
|
||||
style={{ width: "2px", background: "var(--bs-border-color)", minHeight: "16px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: quest info */}
|
||||
<div className={`flex-grow-1 pb-3 ${isLast ? "" : ""}`}>
|
||||
<button
|
||||
className="btn btn-link p-0 text-start text-decoration-none w-100 d-flex align-items-center gap-2"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
type="button"
|
||||
>
|
||||
<span className="fw-semibold">{quest.name}</span>
|
||||
<Icon type={`chevron-${open ? "up" : "down"}`} />
|
||||
{questSub && !isAchieved && (
|
||||
<Badge bg="secondary" className="ms-auto">
|
||||
{questSub.progress} / {goalTotal}
|
||||
</Badge>
|
||||
)}
|
||||
{isAchieved && (
|
||||
<Badge bg="success" className="ms-auto">
|
||||
<Icon type="check" /> Done
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Collapse in={open}>
|
||||
<div className="mt-2">
|
||||
<p className="text-body-secondary small mb-2">{quest.desc}</p>
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<Muted>{goalTotal} goals</Muted>
|
||||
<QuestSubscribeButton quest={quest} />
|
||||
<a className="small" href={`#${quest.questID}`}>
|
||||
View details ↓
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestSubscribeButton({ quest }: { quest: QuestDocument }) {
|
||||
const { settings } = useLUGPTSettings();
|
||||
const { questSubs, reloadTargets } = useContext(TargetsContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const v3Game = quest.game;
|
||||
const questSub = questSubs.get(quest.questID);
|
||||
|
||||
if (!questSub) {
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (questSub) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-100 d-flex">
|
||||
<div>
|
||||
<Icon
|
||||
style={{ verticalAlign: "middle", fontSize: "0.4rem" }}
|
||||
type="circle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a className="text-decoration-none ms-2" href={`#${quest.questID}`}>
|
||||
{quest.name}
|
||||
</a>
|
||||
|
||||
{settings && (
|
||||
<div className="ms-auto text-danger">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{ method: "PUT" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
reloadTargets();
|
||||
}}
|
||||
variant="outline-success"
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<QuickTooltip
|
||||
tooltipContent={
|
||||
questSub.achieved
|
||||
? `Achieved on ${FormatTime(questSub.timeAchieved ?? 0)}`
|
||||
: questSub.lastInteraction
|
||||
? `Last raised on ${FormatTime(questSub.lastInteraction ?? 0)}`
|
||||
: "Freshly assigned!"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{ method: "DELETE" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
setLoading(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
{loading ? "…" : <><Icon type="trash" /> Unsubscribe</>}
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-100 d-flex">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{ method: "PUT" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
setLoading(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-success"
|
||||
>
|
||||
{loading ? "…" : <><Icon type="scroll" /> Subscribe</>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy component kept for compatibility — now delegates to QuestTimelineNode via Questline. */
|
||||
export function InnerQuestInfo({ quest }: { quest: QuestDocument }) {
|
||||
const { questSubs } = useContext(TargetsContext);
|
||||
const questSub = questSubs.get(quest.questID);
|
||||
const v3Game = quest.game;
|
||||
const goalTotal = GetGoalIDsFromQuest(quest).length;
|
||||
|
||||
return (
|
||||
<div className="w-100 d-flex align-items-center gap-2">
|
||||
{questSub ? (
|
||||
<QuickTooltip
|
||||
tooltipContent={
|
||||
questSub.achieved
|
||||
? `Achieved on ${FormatTime(questSub.timeAchieved ?? 0)}`
|
||||
: questSub.lastInteraction
|
||||
? `Last raised on ${FormatTime(questSub.lastInteraction ?? 0)}`
|
||||
: `Freshly Assigned!`
|
||||
: "Freshly assigned!"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{questSub.achieved ? (
|
||||
<Icon
|
||||
colour="success"
|
||||
regular
|
||||
style={{ verticalAlign: "middle" }}
|
||||
type="check-square"
|
||||
/>
|
||||
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
|
||||
) : (
|
||||
<Icon
|
||||
colour="danger"
|
||||
regular
|
||||
style={{ verticalAlign: "middle" }}
|
||||
type="square"
|
||||
/>
|
||||
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
|
||||
)}
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.4rem", verticalAlign: "middle" }} type="circle" />
|
||||
)}
|
||||
|
||||
<a className="text-decoration-none ms-2" href={`#${quest.questID}`}>
|
||||
{quest.name}
|
||||
</a>
|
||||
<a className="text-decoration-none" href={`#${quest.questID}`}>
|
||||
{quest.name}
|
||||
</a>
|
||||
|
||||
{!questSub.achieved && (
|
||||
<div className="ms-auto text-danger">
|
||||
<span className="text-danger">{questSub.progress}</span>
|
||||
<Muted> / {GetGoalIDsFromQuest(quest).length}</Muted>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{questSub && !questSub.achieved && (
|
||||
<div className="ms-auto text-danger small">
|
||||
<span>{questSub.progress}</span>
|
||||
<Muted> / {goalTotal}</Muted>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { GetGoalIDsFromQuest } from "#util/data";
|
||||
import { HumanisedJoinArray } from "#util/misc";
|
||||
import { FormatTime } from "#util/time";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { Badge, Button, ProgressBar } from "react-bootstrap";
|
||||
import {
|
||||
FormatGame,
|
||||
type GoalDocument,
|
||||
@@ -31,120 +31,117 @@ export default function Quest({
|
||||
quest: QuestDocument;
|
||||
}) {
|
||||
const { user } = useContext(UserContext);
|
||||
const { questSubs, reloadTargets } = useContext(TargetsContext);
|
||||
const { questSubs, goalSubs, reloadTargets } = useContext(TargetsContext);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
|
||||
const questSub = questSubs.get(quest.questID);
|
||||
const goalsInQuests = GetGoalIDsFromQuest(quest).length;
|
||||
const v3Game = quest.game;
|
||||
|
||||
// if this is collapsable, show it conditionally. otherwise, always show it.
|
||||
const allGoalIDs = GetGoalIDsFromQuest(quest);
|
||||
const totalGoals = allGoalIDs.length;
|
||||
const achievedGoals = allGoalIDs.filter((id) => goalSubs.get(id)?.achieved).length;
|
||||
const progressPct = totalGoals > 0 ? Math.round((achievedGoals / totalGoals) * 100) : 0;
|
||||
|
||||
const [show, setShow] = useState(!collapsible);
|
||||
|
||||
const subscribeBtn = user && (
|
||||
questSub ? (
|
||||
<Button
|
||||
disabled={subscribing}
|
||||
onClick={async () => {
|
||||
setSubscribing(true);
|
||||
await APIFetchV1(
|
||||
`/users/${user.id}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{ method: "DELETE" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
setSubscribing(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
{subscribing ? "Unsubscribing…" : <><Icon type="trash" /> Unsubscribe</>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={subscribing}
|
||||
onClick={async () => {
|
||||
setSubscribing(true);
|
||||
await APIFetchV1(
|
||||
`/users/${user.id}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{ method: "PUT" },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
setSubscribing(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
{subscribing ? "Subscribing…" : <><Icon type="scroll" /> Subscribe</>}
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
header={
|
||||
<div>
|
||||
<h3>{quest.name}</h3>
|
||||
|
||||
<div>{quest.desc}</div>
|
||||
<div className="mt-4">
|
||||
<Muted>Game: {FormatGame(quest.game)}</Muted>
|
||||
<div className="w-100">
|
||||
<div className="d-flex align-items-start justify-content-between gap-3">
|
||||
<div className="flex-grow-1 min-w-0">
|
||||
<h4 className="mb-1">{quest.name}</h4>
|
||||
<p className="text-body-secondary mb-1 small">{quest.desc}</p>
|
||||
<Muted>{FormatGame(quest.game)}</Muted>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{subscribeBtn}</div>
|
||||
</div>
|
||||
|
||||
{/* Overall progress bar — visible when subscribed */}
|
||||
{questSub && (
|
||||
<div className="w-100 mt-4">
|
||||
<h4>
|
||||
Progress:{" "}
|
||||
{questSub.achieved ? (
|
||||
<Badge bg="success">COMPLETE!</Badge>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-danger">{questSub.progress}</span>
|
||||
<Muted>/{goalsInQuests}</Muted>
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="mt-3">
|
||||
{questSub.achieved ? (
|
||||
<Badge bg="success" className="py-2 px-3">
|
||||
<Icon type="check" /> Quest Complete!
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="d-flex justify-content-between small text-body-secondary mb-1">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{achievedGoals} / {totalGoals} goals
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
className="rounded-pill"
|
||||
label={`${progressPct}%`}
|
||||
now={progressPct}
|
||||
striped={progressPct > 0 && progressPct < 100}
|
||||
style={{ height: "12px" }}
|
||||
variant={progressPct === 100 ? "success" : "primary"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex w-100 mt-4">
|
||||
{user &&
|
||||
(questSub ? (
|
||||
<Button
|
||||
className="ms-auto"
|
||||
disabled={subscribing}
|
||||
onClick={async () => {
|
||||
setSubscribing(true);
|
||||
|
||||
await APIFetchV1(
|
||||
`/users/${user.id}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
|
||||
setSubscribing(false);
|
||||
}}
|
||||
variant="outline-danger"
|
||||
>
|
||||
{subscribing ? (
|
||||
"Unsubscribing..."
|
||||
) : (
|
||||
<>
|
||||
<Icon type="trash" /> Unsubscribe
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="ms-auto"
|
||||
disabled={subscribing}
|
||||
onClick={async () => {
|
||||
setSubscribing(true);
|
||||
|
||||
await APIFetchV1(
|
||||
`/users/${user.id}/games/${v3Game}/targets/quests/${quest.questID}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await reloadTargets();
|
||||
|
||||
setSubscribing(false);
|
||||
}}
|
||||
variant="outline-success"
|
||||
>
|
||||
{subscribing ? (
|
||||
"Subscribing..."
|
||||
) : (
|
||||
<>
|
||||
<Icon type="scroll" /> Subscribe to Quest
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{show &&
|
||||
quest.questData.map((e, i) => (
|
||||
quest.questData.map((section, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<QuestSectionComponent game={quest.game} goals={goals} section={e} />
|
||||
<Divider />
|
||||
<QuestSectionComponent game={quest.game} goals={goals} section={section} />
|
||||
{i < quest.questData.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{collapsible && (
|
||||
<div
|
||||
className="d-flex w-100 justify-content-center text-hover-white"
|
||||
className="d-flex w-100 justify-content-center text-hover-white mt-2"
|
||||
onClick={() => setShow(!show)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<Icon type={`chevron-${show ? "up" : "down"}`} />
|
||||
</div>
|
||||
@@ -161,35 +158,138 @@ function QuestSectionComponent({
|
||||
goals: Map<string, GoalDocument>;
|
||||
section: QuestSection;
|
||||
} & GamePT) {
|
||||
const { goalSubs } = useContext(TargetsContext);
|
||||
|
||||
const achievedInSection = section.goals.filter((g) => goalSubs.get(g.goalID)?.achieved).length;
|
||||
const totalInSection = section.goals.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h5>{section.title}</h5>
|
||||
{section.desc && <div>{section.desc}</div>}
|
||||
<br />
|
||||
<div className="mb-2">
|
||||
{/* Section header with mini-progress badge */}
|
||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||
<h6 className="mb-0 fw-semibold text-body-secondary text-uppercase small letter-spacing-wide">
|
||||
{section.title}
|
||||
</h6>
|
||||
{totalInSection > 0 && (
|
||||
<Badge
|
||||
bg={achievedInSection === totalInSection ? "success" : "secondary"}
|
||||
className="ms-2"
|
||||
>
|
||||
{achievedInSection} / {totalInSection}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{section.desc && (
|
||||
<p className="text-body-secondary small mb-2">{section.desc}</p>
|
||||
)}
|
||||
<hr className="mt-0 mb-3 opacity-10" />
|
||||
|
||||
{section.goals.length === 0 ? (
|
||||
<Muted>No Goals...</Muted>
|
||||
<Muted>No Goals…</Muted>
|
||||
) : (
|
||||
<div className="ps-6">
|
||||
{section.goals.map((e, i) => {
|
||||
const goal = goals.get(e.goalID);
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{section.goals.map((goalRef, i) => {
|
||||
const goal = goals.get(goalRef.goalID);
|
||||
|
||||
if (!goal) {
|
||||
return (
|
||||
<div key={i}>
|
||||
Unknown goal '{e.goalID}'. This should never happen.
|
||||
<div className="text-danger small" key={i}>
|
||||
Unknown goal '{goalRef.goalID}'
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2" key={i}>
|
||||
<InnerQuestSectionGoal goal={goal} note={e.note} />
|
||||
</div>
|
||||
<GoalCard game={game} goal={goal} key={i} note={goalRef.note} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A richer goal card showing progress, chart context, and an optional edit button. */
|
||||
function GoalCard({
|
||||
goal,
|
||||
note,
|
||||
game: _game,
|
||||
onEdit,
|
||||
}: {
|
||||
goal: GoalDocument;
|
||||
note?: string;
|
||||
onEdit?: () => void;
|
||||
} & GamePT) {
|
||||
const { goalSubs } = useContext(TargetsContext);
|
||||
const goalSub = goalSubs.get(goal.goalID);
|
||||
|
||||
const isAchieved = goalSub?.achieved ?? false;
|
||||
const inProgress = goalSub && !isAchieved && goalSub.progress !== null && goalSub.progress > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`d-flex align-items-start gap-3 rounded p-2 ${
|
||||
isAchieved
|
||||
? "bg-success bg-opacity-10"
|
||||
: inProgress
|
||||
? "bg-warning bg-opacity-10"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
{goalSub ? (
|
||||
<QuickTooltip
|
||||
tooltipContent={
|
||||
isAchieved
|
||||
? `Achieved on ${FormatTime(goalSub.timeAchieved ?? 0)}`
|
||||
: goalSub.lastInteraction
|
||||
? `Last raised on ${FormatTime(goalSub.lastInteraction)}`
|
||||
: "Never attempted"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{isAchieved ? (
|
||||
<Icon colour="success" regular type="check-square" />
|
||||
) : inProgress ? (
|
||||
<Icon colour="warning" regular type="square" />
|
||||
) : (
|
||||
<Icon colour="secondary" regular type="square" />
|
||||
)}
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.5rem", verticalAlign: "middle" }} type="circle" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Goal name + note */}
|
||||
<div className="flex-grow-1 min-w-0">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<GoalLink goal={goal} />
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-link btn-sm p-0 text-body-secondary"
|
||||
onClick={onEdit}
|
||||
title="Update this goal"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="pencil" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{note && <span className="text-body-secondary small">{note}</span>}
|
||||
</div>
|
||||
|
||||
{/* Progress chip — only shown when subscribed and not achieved */}
|
||||
{goalSub && !isAchieved && (
|
||||
<div className="flex-shrink-0 text-end small">
|
||||
<span className={inProgress ? "text-warning" : "text-body-secondary"}>
|
||||
{goalSub.progressHuman}
|
||||
</span>
|
||||
<Muted> / {goalSub.outOfHuman}</Muted>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,11 +299,13 @@ export function InnerQuestSectionGoal({
|
||||
note,
|
||||
dependencies,
|
||||
goalSubOverride,
|
||||
onEdit,
|
||||
}: {
|
||||
dependencies?: string[];
|
||||
goal: GoalDocument;
|
||||
goalSubOverride?: GoalSubscriptionDocument;
|
||||
note?: string;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const { goalSubs } = useContext(TargetsContext);
|
||||
|
||||
@@ -212,57 +314,72 @@ export function InnerQuestSectionGoal({
|
||||
if (!goalSub) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-100 d-flex">
|
||||
<div>
|
||||
<Icon
|
||||
style={{ verticalAlign: "middle", fontSize: "0.4rem" }}
|
||||
type="circle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-100 d-flex align-items-center gap-2">
|
||||
<Icon
|
||||
style={{ verticalAlign: "middle", fontSize: "0.4rem" }}
|
||||
type="circle"
|
||||
/>
|
||||
<GoalLink goal={goal} />
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-link btn-sm p-0 text-body-secondary"
|
||||
onClick={onEdit}
|
||||
title="Update this goal"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="pencil" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{note && <Muted>{note}</Muted>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isAchieved = goalSub.achieved;
|
||||
const inProgress = !isAchieved && goalSub.progress !== null && goalSub.progress > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-100 d-flex">
|
||||
<div className="w-100 d-flex align-items-center gap-2">
|
||||
<QuickTooltip
|
||||
tooltipContent={
|
||||
goalSub.achieved
|
||||
isAchieved
|
||||
? `Achieved on ${FormatTime(goalSub.timeAchieved)}`
|
||||
: goalSub.lastInteraction
|
||||
? `Last raised on ${FormatTime(goalSub.lastInteraction)}`
|
||||
: `Never Attempted.`
|
||||
: "Never attempted"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{goalSub.achieved ? (
|
||||
<Icon
|
||||
colour="success"
|
||||
regular
|
||||
style={{ verticalAlign: "middle" }}
|
||||
type="check-square"
|
||||
/>
|
||||
{isAchieved ? (
|
||||
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
|
||||
) : inProgress ? (
|
||||
<Icon colour="warning" regular style={{ verticalAlign: "middle" }} type="square" />
|
||||
) : (
|
||||
<Icon
|
||||
colour="danger"
|
||||
regular
|
||||
style={{ verticalAlign: "middle" }}
|
||||
type="square"
|
||||
/>
|
||||
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
|
||||
)}
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
|
||||
<GoalLink goal={goal} />
|
||||
|
||||
{!goalSub.achieved && (
|
||||
<div className="ms-auto text-end text-danger">
|
||||
<span className="text-danger">{goalSub.progressHuman}</span>
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-link btn-sm p-0 text-body-secondary"
|
||||
onClick={onEdit}
|
||||
title="Update this goal"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="pencil" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isAchieved && (
|
||||
<div className="ms-auto text-end small">
|
||||
<span className={inProgress ? "text-warning" : "text-danger"}>
|
||||
{goalSub.progressHuman}
|
||||
</span>
|
||||
<Muted> / {goalSub.outOfHuman}</Muted>
|
||||
</div>
|
||||
)}
|
||||
@@ -282,8 +399,9 @@ export function InnerQuestSectionGoal({
|
||||
|
||||
function FormatGoalDependencies({ deps, isStandalone }: { deps: string[]; isStandalone: boolean }) {
|
||||
let str;
|
||||
|
||||
if (isStandalone && deps.length === 0) {
|
||||
str = `You set this goal directly.`;
|
||||
str = "You set this goal directly.";
|
||||
} else if (isStandalone && deps.length > 0) {
|
||||
str = `You set this goal, but it's also in ${HumanisedJoinArray(deps, "and")}.`;
|
||||
} else if (deps.length === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import QuickTooltip from "#components/layout/misc/QuickTooltip";
|
||||
import Card from "#components/layout/page/Card";
|
||||
import AddNewGoalForQuestModal from "#components/targets/AddNewGoalForQuestModal";
|
||||
import GoalBuilder from "#components/targets/GoalBuilder";
|
||||
import Divider from "#components/util/Divider";
|
||||
import EditableText from "#components/util/EditableText";
|
||||
import Icon from "#components/util/Icon";
|
||||
@@ -9,7 +9,7 @@ import { type GamePT } from "#types/react";
|
||||
import { type RawQuestDocument, type RawQuestGoal, type RawQuestSection } from "#types/tachi";
|
||||
import { ChangeAtPosition, CopyToClipboard, DeleteInPosition } from "#util/misc";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Button, Collapse, Modal } from "react-bootstrap";
|
||||
import { FormatGame } from "tachi-common";
|
||||
|
||||
export default function EditableQuest({
|
||||
@@ -26,35 +26,23 @@ export default function EditableQuest({
|
||||
header={
|
||||
<div className="vstack gap-2">
|
||||
<EditableText
|
||||
as="h1"
|
||||
as="h4"
|
||||
authorised
|
||||
initialText={quest.name}
|
||||
onSubmit={(name) =>
|
||||
onChange({
|
||||
...quest,
|
||||
name,
|
||||
})
|
||||
}
|
||||
onSubmit={(name) => onChange({ ...quest, name })}
|
||||
placeholderText={quest.name || "Untitled Quest"}
|
||||
/>
|
||||
|
||||
<EditableText
|
||||
authorised
|
||||
initialText={quest.desc}
|
||||
onSubmit={(desc) =>
|
||||
onChange({
|
||||
...quest,
|
||||
desc,
|
||||
})
|
||||
}
|
||||
onSubmit={(desc) => onChange({ ...quest, desc })}
|
||||
placeholderText={quest.desc || "Please set a description."}
|
||||
/>
|
||||
|
||||
<Muted>Game: {FormatGame(quest.game)}</Muted>
|
||||
<Muted>{FormatGame(quest.game)}</Muted>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{quest.rawQuestData.map((e, i) => (
|
||||
{quest.rawQuestData.map((section, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<QuestSection
|
||||
game={quest.game}
|
||||
@@ -65,16 +53,32 @@ export default function EditableQuest({
|
||||
})
|
||||
}
|
||||
onDelete={() =>
|
||||
onChange({
|
||||
...quest,
|
||||
rawQuestData: DeleteInPosition(quest.rawQuestData, i),
|
||||
})
|
||||
onChange({ ...quest, rawQuestData: DeleteInPosition(quest.rawQuestData, i) })
|
||||
}
|
||||
section={e}
|
||||
onMoveDown={
|
||||
i < quest.rawQuestData.length - 1
|
||||
? () => {
|
||||
const arr = [...quest.rawQuestData];
|
||||
[arr[i], arr[i + 1]] = [arr[i + 1]!, arr[i]!];
|
||||
onChange({ ...quest, rawQuestData: arr });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMoveUp={
|
||||
i > 0
|
||||
? () => {
|
||||
const arr = [...quest.rawQuestData];
|
||||
[arr[i - 1], arr[i]] = [arr[i]!, arr[i - 1]!];
|
||||
onChange({ ...quest, rawQuestData: arr });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
section={section}
|
||||
/>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<Button
|
||||
onClick={() =>
|
||||
@@ -82,38 +86,31 @@ export default function EditableQuest({
|
||||
...quest,
|
||||
rawQuestData: [
|
||||
...quest.rawQuestData,
|
||||
{
|
||||
title: "Untitled Section",
|
||||
desc: "",
|
||||
rawGoals: [],
|
||||
},
|
||||
{ title: "New Section", desc: "", rawGoals: [] },
|
||||
],
|
||||
})
|
||||
}
|
||||
variant="outline-success"
|
||||
>
|
||||
<Icon type="plus" />
|
||||
Add New Quest Section
|
||||
<Icon type="plus" /> Add Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<div className="d-flex w-100">
|
||||
<div className="me-auto">
|
||||
<QuickTooltip tooltipContent="Copy this quest to your clipboard in a pretty format.">
|
||||
<Button
|
||||
onClick={() => {
|
||||
CopyToClipboard(FormatQuest(quest));
|
||||
}}
|
||||
variant="outline-info"
|
||||
>
|
||||
Copy To Clipboard
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
</div>
|
||||
|
||||
<div className="d-flex w-100 gap-2">
|
||||
<QuickTooltip tooltipContent="Copy this quest as a readable summary.">
|
||||
<Button
|
||||
onClick={() => CopyToClipboard(FormatQuestAsText(quest))}
|
||||
variant="outline-info"
|
||||
>
|
||||
Copy Summary
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
<div className="ms-auto">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm("Are you absolutely sure you want to delete this quest?")) {
|
||||
if (confirm("Delete this quest?")) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
@@ -132,158 +129,229 @@ function QuestSection({
|
||||
game,
|
||||
onChange,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: {
|
||||
onChange: (newSection: RawQuestSection) => void;
|
||||
onDelete: () => void;
|
||||
onMoveDown?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
section: RawQuestSection;
|
||||
} & GamePT) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [showGoalBuilder, setShowGoalBuilder] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vstack gap-2">
|
||||
<EditableText
|
||||
as="h4"
|
||||
authorised
|
||||
initialText={section.title}
|
||||
onSubmit={(title) =>
|
||||
onChange({
|
||||
...section,
|
||||
title,
|
||||
})
|
||||
}
|
||||
placeholderText="Untitled Section"
|
||||
/>
|
||||
|
||||
<EditableText
|
||||
authorised
|
||||
initialText={section.desc}
|
||||
onSubmit={(desc) =>
|
||||
onChange({
|
||||
...section,
|
||||
desc,
|
||||
})
|
||||
}
|
||||
placeholderText="No Description..."
|
||||
/>
|
||||
<div>
|
||||
<div className="d-flex align-items-start gap-2 mb-2">
|
||||
<div className="flex-grow-1">
|
||||
<EditableText
|
||||
as="h5"
|
||||
authorised
|
||||
initialText={section.title}
|
||||
onSubmit={(title) => onChange({ ...section, title })}
|
||||
placeholderText="Section Title"
|
||||
/>
|
||||
<EditableText
|
||||
authorised
|
||||
initialText={section.desc}
|
||||
onSubmit={(desc) => onChange({ ...section, desc })}
|
||||
placeholderText="Optional section description…"
|
||||
/>
|
||||
</div>
|
||||
{/* Reorder buttons */}
|
||||
<div className="d-flex flex-column gap-1">
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={!onMoveUp}
|
||||
onClick={onMoveUp}
|
||||
title="Move section up"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-up" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={!onMoveDown}
|
||||
onClick={onMoveDown}
|
||||
title="Move section down"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
{section.rawGoals.length === 0 ? (
|
||||
<Muted>No Goals...</Muted>
|
||||
<span className="text-body-secondary small">No goals yet.</span>
|
||||
) : (
|
||||
<ul>
|
||||
{section.rawGoals.map((e, i) => (
|
||||
<InnerQuestSectionGoal
|
||||
<div className="d-flex flex-column gap-1 mb-2">
|
||||
{section.rawGoals.map((rawGoal, i) => (
|
||||
<EditableGoalRow
|
||||
game={game}
|
||||
key={i}
|
||||
onInnerGoalChange={(newRawGoal) =>
|
||||
onChange({
|
||||
...section,
|
||||
rawGoals: ChangeAtPosition(section.rawGoals, newRawGoal, i),
|
||||
})
|
||||
}
|
||||
onInnerGoalDelete={() => {
|
||||
onDelete={() =>
|
||||
onChange({
|
||||
...section,
|
||||
rawGoals: DeleteInPosition(section.rawGoals, i),
|
||||
});
|
||||
}}
|
||||
rawGoal={e}
|
||||
})
|
||||
}
|
||||
onMoveDown={
|
||||
i < section.rawGoals.length - 1
|
||||
? () => {
|
||||
const arr = [...section.rawGoals];
|
||||
[arr[i], arr[i + 1]] = [arr[i + 1]!, arr[i]!];
|
||||
onChange({ ...section, rawGoals: arr });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMoveUp={
|
||||
i > 0
|
||||
? () => {
|
||||
const arr = [...section.rawGoals];
|
||||
[arr[i - 1], arr[i]] = [arr[i]!, arr[i - 1]!];
|
||||
onChange({ ...section, rawGoals: arr });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(newGoal) =>
|
||||
onChange({
|
||||
...section,
|
||||
rawGoals: ChangeAtPosition(section.rawGoals, newGoal, i),
|
||||
})
|
||||
}
|
||||
rawGoal={rawGoal}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
<div className="w-100 d-flex mt-8">
|
||||
<Button onClick={() => setShow(true)} variant="outline-success">
|
||||
|
||||
<div className="d-flex gap-2 mt-2">
|
||||
<Button onClick={() => setShowGoalBuilder(true)} size="sm" variant="outline-success">
|
||||
<Icon type="plus" /> Add Goal
|
||||
</Button>
|
||||
<Button
|
||||
className="ms-auto"
|
||||
onClick={() => {
|
||||
if (confirm("Are you absolutely sure you want to delete this section?")) {
|
||||
if (confirm("Delete this section?")) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
<Icon type="times" /> Delete Section
|
||||
</Button>
|
||||
</div>
|
||||
{show && (
|
||||
<AddNewGoalForQuestModal
|
||||
game={game}
|
||||
onCreate={(rawGoal) => {
|
||||
onChange({
|
||||
...section,
|
||||
rawGoals: [...section.rawGoals, rawGoal],
|
||||
});
|
||||
}}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* GoalBuilder modal */}
|
||||
<Modal onHide={() => setShowGoalBuilder(false)} show={showGoalBuilder} size="xl">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Add Goal to "{section.title}"</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<GoalBuilder
|
||||
confirmLabel="Add Goal"
|
||||
game={game}
|
||||
onCreate={(rawGoal) => {
|
||||
onChange({ ...section, rawGoals: [...section.rawGoals, rawGoal] });
|
||||
setShowGoalBuilder(false);
|
||||
}}
|
||||
showNote
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerQuestSectionGoal({
|
||||
function EditableGoalRow({
|
||||
rawGoal,
|
||||
game,
|
||||
onInnerGoalChange,
|
||||
onInnerGoalDelete,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: {
|
||||
onInnerGoalChange: (newRawGoal: RawQuestGoal) => void;
|
||||
onInnerGoalDelete: () => void;
|
||||
onDelete: () => void;
|
||||
onMoveDown?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onUpdate: (newGoal: RawQuestGoal) => void;
|
||||
rawGoal: RawQuestGoal;
|
||||
} & GamePT) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
|
||||
return (
|
||||
<li className="quest-goal">
|
||||
<div className="w-100 d-flex">
|
||||
<div className="me-auto">{rawGoal.goal.name}</div>
|
||||
<div className="d-flex align-items-center gap-2 rounded border px-2 py-1">
|
||||
<Icon style={{ fontSize: "0.4rem" }} type="circle" />
|
||||
<span className="flex-grow-1 small">{rawGoal.goal.name}</span>
|
||||
{rawGoal.note && <span className="text-body-secondary small fst-italic">{rawGoal.note}</span>}
|
||||
|
||||
<div className="ms-auto d-flex flex-nowrap">
|
||||
<div className="text-hover-white">
|
||||
<Icon onClick={() => setShow(true)} type="pencil-alt" />
|
||||
</div>
|
||||
<div className="ms-2 text-hover-white">
|
||||
<Icon
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to remove the goal "${rawGoal.goal.name}"?`,
|
||||
)
|
||||
) {
|
||||
onInnerGoalDelete();
|
||||
}
|
||||
}}
|
||||
type="trash"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{rawGoal.note && <Muted>{rawGoal.note}</Muted>}
|
||||
{show && (
|
||||
<AddNewGoalForQuestModal
|
||||
game={game}
|
||||
initialState={rawGoal}
|
||||
onCreate={(newRawGoal) => {
|
||||
onInnerGoalChange(newRawGoal);
|
||||
<div className="d-flex gap-1 ms-auto">
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={!onMoveUp}
|
||||
onClick={onMoveUp}
|
||||
title="Move up"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-up" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm py-0"
|
||||
disabled={!onMoveDown}
|
||||
onClick={onMoveDown}
|
||||
title="Move down"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="chevron-down" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-warning btn-sm py-0"
|
||||
onClick={() => setShowEdit(true)}
|
||||
title="Edit goal"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="pencil" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm py-0"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove goal "${rawGoal.goal.name}"?`)) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
/>
|
||||
title="Remove goal"
|
||||
type="button"
|
||||
>
|
||||
<Icon type="trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEdit && (
|
||||
<Modal onHide={() => setShowEdit(false)} show size="xl">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Edit Goal</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<GoalBuilder
|
||||
confirmLabel="Update Goal"
|
||||
existingGoal={{ ...rawGoal.goal, goalID: "" } as any}
|
||||
game={game}
|
||||
onCreate={(updated) => {
|
||||
onUpdate(updated);
|
||||
setShowEdit(false);
|
||||
}}
|
||||
showNote
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatQuest(quest: RawQuestDocument) {
|
||||
let str = `# QUEST: ${quest.name}
|
||||
${quest.desc}
|
||||
(Game: ${FormatGame(quest.game)})`;
|
||||
function FormatQuestAsText(quest: RawQuestDocument): string {
|
||||
let str = `# ${quest.name}\n${quest.desc}\n(${FormatGame(quest.game)})`;
|
||||
|
||||
for (const section of quest.rawQuestData) {
|
||||
str += `\n\n### ${section.title}`;
|
||||
@@ -295,10 +363,10 @@ ${quest.desc}
|
||||
str += "\n";
|
||||
|
||||
for (const goal of section.rawGoals) {
|
||||
str += `\n-- ${goal.goal.name}`;
|
||||
str += `\n- ${goal.goal.name}`;
|
||||
|
||||
if (goal.note) {
|
||||
str += `\n${goal.note}`;
|
||||
str += `\n ${goal.note}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +71,18 @@ export type RawQuestGoal = {
|
||||
goal: Pick<GoalDocument, "charts" | "criteria" | "name">;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A questline document without a server-assigned `questlineID`. Used when
|
||||
* authoring questlines locally in the Quest Editor before uploading as seeds.
|
||||
*/
|
||||
export type RawQuestlineDocument = {
|
||||
/** User-chosen slug / identifier (becomes `questlineID` in seeds). */
|
||||
questlineID: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
game: string;
|
||||
playtype: string;
|
||||
/** Ordered list of `RawQuestDocument` references by their local index or slug. */
|
||||
quests: Array<string>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* After charts + folders gained Postgres-style ids (`id` plus `legacyChartID` /
|
||||
* `legacyFolderID`), `goals.json` may still reference legacy chart hashes and legacy
|
||||
* folder ids inside `charts.data`. Remap those references, recalculate `goalID` hashes
|
||||
* (same `{ charts, criteria, game }` convention as ../../util CreateGoalID), rewrite
|
||||
* `quests.json` goal refs when ids change, and write `goal-id-remap.json` so
|
||||
* mongo→postgres can translate subscriptions.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { CreateGoalID } from "../../util";
|
||||
|
||||
type GoalCharts =
|
||||
| { data: Array<string>; type: "multi" }
|
||||
| { data: string; folderSlug?: string; type: "folder" }
|
||||
| { data: string; type: "single" };
|
||||
|
||||
interface GoalSeed {
|
||||
charts: GoalCharts;
|
||||
criteria:
|
||||
| {
|
||||
countNum: number;
|
||||
key: string;
|
||||
mode: "absolute" | "proportion";
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
mode: "single";
|
||||
value: number;
|
||||
};
|
||||
game: string;
|
||||
goalID: string;
|
||||
name: string;
|
||||
playtype: string;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SEEDS_DIR = path.join(__dirname, "../../../../db/seeds");
|
||||
|
||||
const LEGACY_CHART_HEX_RE = /^[0-9a-f]{40}$/u;
|
||||
|
||||
function readJsonArray<T>(filePath: string): Array<T> {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as Array<T>;
|
||||
}
|
||||
|
||||
function buildChartLegacyToIdMaps(): {
|
||||
canonicalChartIds: Set<string>;
|
||||
legacyToSeedId: Map<string, string>;
|
||||
} {
|
||||
const legacyToSeedId = new Map<string, string>();
|
||||
const canonicalChartIds = new Set<string>();
|
||||
|
||||
for (const name of fs.readdirSync(SEEDS_DIR)) {
|
||||
if (!/^charts-/u.test(name) || !name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const charts = readJsonArray<{ id: string; legacyChartID?: string }>(
|
||||
path.join(SEEDS_DIR, name),
|
||||
);
|
||||
|
||||
for (const c of charts) {
|
||||
canonicalChartIds.add(c.id);
|
||||
const leg = c.legacyChartID;
|
||||
|
||||
if (leg) {
|
||||
legacyToSeedId.set(leg.toLowerCase(), c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { canonicalChartIds, legacyToSeedId };
|
||||
}
|
||||
|
||||
function buildFolderLegacyToIdMaps(): {
|
||||
canonicalFolderIds: Set<string>;
|
||||
legacyToSeedId: Map<string, string>;
|
||||
} {
|
||||
const legacyToSeedId = new Map<string, string>();
|
||||
const canonicalFolderIds = new Set<string>();
|
||||
const folders = readJsonArray<{
|
||||
id: string;
|
||||
legacyFolderID: string;
|
||||
}>(path.join(SEEDS_DIR, "folders.json"));
|
||||
|
||||
for (const f of folders) {
|
||||
canonicalFolderIds.add(f.id);
|
||||
|
||||
const leg = f.legacyFolderID;
|
||||
|
||||
if (leg !== undefined && leg !== "") {
|
||||
legacyToSeedId.set(leg.trim(), f.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { canonicalFolderIds, legacyToSeedId };
|
||||
}
|
||||
|
||||
function resolveChartGoalRef(
|
||||
candidate: string,
|
||||
legacyToSeedId: Map<string, string>,
|
||||
canonicalChartIds: Set<string>,
|
||||
context: string,
|
||||
legacyHexRe: RegExp,
|
||||
): string {
|
||||
const t = candidate.trim();
|
||||
const lowered = t.toLowerCase();
|
||||
|
||||
if (canonicalChartIds.has(t)) {
|
||||
return t;
|
||||
}
|
||||
|
||||
const migrated = legacyToSeedId.get(lowered);
|
||||
|
||||
if (migrated !== undefined) {
|
||||
return migrated;
|
||||
}
|
||||
|
||||
if (!legacyHexRe.test(t)) {
|
||||
console.warn(
|
||||
`${context}: chart ref ${JSON.stringify(t)} not in canonical set and not a known legacy SHA — leaving as-is`,
|
||||
);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
throw new Error(`${context}: unknown legacy chart SHA ${JSON.stringify(t)}`);
|
||||
}
|
||||
|
||||
function resolveFolderGoalRef(
|
||||
candidate: string,
|
||||
legacyToSeedId: Map<string, string>,
|
||||
canonicalFolderIds: Set<string>,
|
||||
context: string,
|
||||
): string {
|
||||
const t = candidate.trim();
|
||||
|
||||
if (canonicalFolderIds.has(t)) {
|
||||
return t;
|
||||
}
|
||||
|
||||
const migrated = legacyToSeedId.get(t);
|
||||
|
||||
if (migrated !== undefined) {
|
||||
return migrated;
|
||||
}
|
||||
|
||||
throw new Error(`${context}: unknown folder reference ${JSON.stringify(t)}`);
|
||||
}
|
||||
|
||||
function remapChartsBlock(
|
||||
charts: GoalCharts,
|
||||
context: string,
|
||||
legacyHexRe: RegExp,
|
||||
legacyToSeedChart: Map<string, string>,
|
||||
canonicalChartIds: Set<string>,
|
||||
legacyToSeedFolder: Map<string, string>,
|
||||
canonicalFolderIds: Set<string>,
|
||||
): GoalCharts {
|
||||
switch (charts.type) {
|
||||
case "single":
|
||||
return {
|
||||
...charts,
|
||||
data: resolveChartGoalRef(
|
||||
charts.data,
|
||||
legacyToSeedChart,
|
||||
canonicalChartIds,
|
||||
context,
|
||||
legacyHexRe,
|
||||
),
|
||||
};
|
||||
case "multi":
|
||||
return {
|
||||
...charts,
|
||||
data: charts.data.map((cid, i) =>
|
||||
resolveChartGoalRef(
|
||||
cid,
|
||||
legacyToSeedChart,
|
||||
canonicalChartIds,
|
||||
`${context} [multi ${i}]`,
|
||||
legacyHexRe,
|
||||
),
|
||||
),
|
||||
};
|
||||
case "folder":
|
||||
return {
|
||||
...charts,
|
||||
data: resolveFolderGoalRef(charts.data, legacyToSeedFolder, canonicalFolderIds, context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const translateMap = new Map<string, string>();
|
||||
|
||||
const goalsPath = path.join(SEEDS_DIR, "goals.json");
|
||||
const goals = readJsonArray<GoalSeed>(goalsPath);
|
||||
|
||||
const { canonicalChartIds, legacyToSeedId: chartLegacy } = buildChartLegacyToIdMaps();
|
||||
const { canonicalFolderIds, legacyToSeedId: folderLegacy } = buildFolderLegacyToIdMaps();
|
||||
|
||||
for (const goal of goals) {
|
||||
const oldId = goal.goalID;
|
||||
|
||||
goal.charts = remapChartsBlock(
|
||||
goal.charts,
|
||||
`goal ${JSON.stringify(goal.name)} (${oldId})`,
|
||||
LEGACY_CHART_HEX_RE,
|
||||
chartLegacy,
|
||||
canonicalChartIds,
|
||||
folderLegacy,
|
||||
canonicalFolderIds,
|
||||
);
|
||||
|
||||
const newId = CreateGoalID(goal.charts, goal.criteria, goal.game);
|
||||
|
||||
goal.goalID = newId;
|
||||
|
||||
if (newId !== oldId) {
|
||||
translateMap.set(oldId, newId);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`goals.json: remapped chart/folder refs; ${translateMap.size} goal IDs will change`);
|
||||
|
||||
fs.writeFileSync(goalsPath, `${JSON.stringify(goals, null, "\t")}\n`);
|
||||
|
||||
type QuestSeed = {
|
||||
desc: string;
|
||||
game: string;
|
||||
name: string;
|
||||
playtype: string;
|
||||
questData: Array<{
|
||||
desc?: string;
|
||||
goals: Array<{ goalID: string; note?: string }>;
|
||||
title: string;
|
||||
}>;
|
||||
questID: string;
|
||||
};
|
||||
|
||||
const questsPath = path.join(SEEDS_DIR, "quests.json");
|
||||
const quests = readJsonArray<QuestSeed>(questsPath);
|
||||
|
||||
let questsUpdated = 0;
|
||||
|
||||
for (const quest of quests) {
|
||||
let touched = false;
|
||||
|
||||
for (const qd of quest.questData) {
|
||||
for (let i = 0; i < qd.goals.length; i++) {
|
||||
const gRef = qd.goals[i];
|
||||
const nextId = translateMap.get(gRef.goalID);
|
||||
|
||||
if (nextId !== undefined && nextId !== gRef.goalID) {
|
||||
qd.goals[i] = { ...gRef, goalID: nextId };
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
questsUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(questsPath, `${JSON.stringify(quests, null, "\t")}\n`);
|
||||
console.log(`quests.json: updated goal refs on ${questsUpdated} quests`);
|
||||
|
||||
const remapPath = path.join(SEEDS_DIR, "goal-id-remap.json");
|
||||
fs.writeFileSync(remapPath, `${JSON.stringify(Object.fromEntries(translateMap), null, "\t")}\n`);
|
||||
|
||||
console.log(`goal-id-remap.json: wrote ${translateMap.size} translations`);
|
||||
|
||||
const repoRoot = path.resolve(SEEDS_DIR, "..", "..");
|
||||
const biome = path.join(repoRoot, "node_modules", ".bin", "biome");
|
||||
const biomeResult = spawnSync(
|
||||
biome,
|
||||
["format", "--write", goalsPath, questsPath, remapPath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (biomeResult.status !== 0) {
|
||||
throw new Error(`biome format exited ${biomeResult.status}`);
|
||||
}
|
||||
@@ -77,6 +77,11 @@ function SortSeeds() {
|
||||
const collPath = path.join(collectionsDir, collection);
|
||||
let content = JSON.parse(fs.readFileSync(collPath));
|
||||
|
||||
// Auxiliary maps (non-array roots) coexist with seed collections — skip them.
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collection.startsWith("charts-")) {
|
||||
content.sort(ChartSort);
|
||||
} else if (collection.startsWith("songs-")) {
|
||||
|
||||
@@ -20,9 +20,12 @@ function FormatPrError(err, foreword = "Error") {
|
||||
let exitCode = 0;
|
||||
const suites: Array<{ good: boolean; name: string; report: unknown }> = [];
|
||||
|
||||
/** Maps / metadata — not seed document arrays validated by {@link V3_SCHEMAS}. */
|
||||
const NON_SEED_JSON = new Set(["goal-id-remap.json"]);
|
||||
|
||||
const collections = fs
|
||||
.readdirSync(path.join(__dirname, "../../../db/seeds"))
|
||||
.filter((e) => e.endsWith(".json"))
|
||||
.filter((e) => e.endsWith(".json") && !NON_SEED_JSON.has(path.basename(e)))
|
||||
.map((e) => path.basename(e)) as Array<AllCollections>;
|
||||
|
||||
for (const collection of collections) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_AddGoal } from "./add-goal";
|
||||
import { ACTION_UpdateGoalSubscription } from "./update-goal-subscription";
|
||||
|
||||
describe("ACTION_UpdateGoalSubscription", () => {
|
||||
let userID: number;
|
||||
let username: string;
|
||||
let chartId: string;
|
||||
let taker: { ip: string; acct: { id: number; username: string } };
|
||||
|
||||
const baseInput = {
|
||||
game: "iidx-sp" as const,
|
||||
charts: { type: "single" as const, data: "" },
|
||||
criteria: { key: "lamp" as const, value: 7, mode: "single" as const },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID, username } = await seedUser({ username: `goal_update_${Date.now()}` }));
|
||||
chartId = await seedMinimalIidxSpChart();
|
||||
taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
});
|
||||
|
||||
it("throws 403 when targeting another user as non-admin", async () => {
|
||||
const other = await seedUser({ username: `goal_upd_other_${Date.now()}` });
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID: other.id,
|
||||
charts: { type: "single", data: chartId },
|
||||
oldGoalID: "G_fake",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
it("throws 404 when the user is not subscribed to the old goal", async () => {
|
||||
await expect(
|
||||
ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
oldGoalID: "G_does_not_exist",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("throws 400 when the goal was assigned by a quest (not standalone)", async () => {
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
await DB.updateTable("goal_sub")
|
||||
.set({ was_assigned_standalone: false })
|
||||
.where("goal_sub.goal_id", "=", goalID)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.execute();
|
||||
|
||||
const secondChart = await seedMinimalIidxSpChart();
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
oldGoalID: goalID,
|
||||
charts: { type: "single", data: secondChart },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("returns changed: false when the new definition is identical to the old one", async () => {
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
const result = await ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
oldGoalID: goalID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ changed: false, newGoalID: goalID });
|
||||
|
||||
const sub = await GetGoalSubscriptionForIDGuaranteed(goalID, userID);
|
||||
expect(sub).toBeDefined();
|
||||
});
|
||||
|
||||
it("swaps subscription from old goal to new goal", async () => {
|
||||
const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
const secondChart = await seedMinimalIidxSpChart();
|
||||
|
||||
const result = await ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
oldGoalID,
|
||||
charts: { type: "single", data: secondChart },
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.newGoalID).not.toBe(oldGoalID);
|
||||
|
||||
const newGoal = await GetGoalForIDGuaranteed(result.newGoalID);
|
||||
expect(newGoal.charts).toMatchObject({ type: "single", data: secondChart });
|
||||
|
||||
const newSub = await GetGoalSubscriptionForIDGuaranteed(result.newGoalID, userID);
|
||||
expect(newSub).toBeDefined();
|
||||
|
||||
const oldSub = await DB.selectFrom("goal_sub")
|
||||
.selectAll()
|
||||
.where("goal_sub.goal_id", "=", oldGoalID)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(oldSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
const secondChart = await seedMinimalIidxSpChart();
|
||||
|
||||
await ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
oldGoalID,
|
||||
charts: { type: "single", data: secondChart },
|
||||
});
|
||||
|
||||
const actionRow = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "UPDATE_GOAL_SUBSCRIPTION")
|
||||
.orderBy("action.ts_start", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(actionRow).toMatchObject({
|
||||
result: "GOOD",
|
||||
ip: "127.0.0.1",
|
||||
user_id: userID,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 400 when the new chart does not exist", async () => {
|
||||
const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
charts: { type: "single", data: chartId },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateGoalSubscription(taker, {
|
||||
...baseInput,
|
||||
userID,
|
||||
oldGoalID,
|
||||
charts: { type: "single", data: "C_fake_chart" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MakeAction } from "#lib/actions/actions";
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes";
|
||||
import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal";
|
||||
import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals";
|
||||
import DB from "#services/pg/db";
|
||||
import { IsUserAdmin } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GoalDocument } from "tachi-common";
|
||||
|
||||
export const ACTION_UpdateGoalSubscription = MakeAction(
|
||||
"UPDATE_GOAL_SUBSCRIPTION",
|
||||
async (taker, input) => {
|
||||
const { userID, game, oldGoalID, charts, criteria } = input;
|
||||
|
||||
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
|
||||
}
|
||||
|
||||
const existingSubRow = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where("goal_sub.goal_id", "=", oldGoalID)
|
||||
.where("goal.game", "=", game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!existingSubRow) {
|
||||
throw new ExpectedErr(404, "You are not subscribed to this goal.");
|
||||
}
|
||||
|
||||
const existingSub = ToGoalSubscriptionDocument(existingSubRow);
|
||||
|
||||
if (!existingSub.wasAssignedStandalone) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
"This goal was assigned by a quest and cannot be updated directly. Unsubscribe from the quest to modify its goals.",
|
||||
);
|
||||
}
|
||||
|
||||
let newGoal: GoalDocument;
|
||||
|
||||
try {
|
||||
newGoal = await ConstructGoal(
|
||||
charts as GoalDocument["charts"],
|
||||
criteria as GoalDocument["criteria"],
|
||||
game,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new ExpectedErr(400, (e as Error).message);
|
||||
}
|
||||
|
||||
if (newGoal.goalID === oldGoalID) {
|
||||
return { newGoalID: oldGoalID, changed: false };
|
||||
}
|
||||
|
||||
await DB.deleteFrom("goal_sub")
|
||||
.where("goal_sub.goal_id", "=", oldGoalID)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.execute();
|
||||
|
||||
const subResult = await SubscribeToGoal(userID, newGoal, true);
|
||||
|
||||
if (
|
||||
subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED ||
|
||||
subResult === SubscribeFailReasons.ALREADY_ACHIEVED
|
||||
) {
|
||||
throw new ExpectedErr(409, "Your updated goal conflicts with an existing subscription.");
|
||||
}
|
||||
|
||||
return { newGoalID: newGoal.goalID, changed: true };
|
||||
},
|
||||
);
|
||||
@@ -349,6 +349,19 @@ export const ActionSignatures = {
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
UPDATE_GOAL_SUBSCRIPTION: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
game: z.enum(ALL_GAMES),
|
||||
oldGoalID: z.string(),
|
||||
charts: z.any(),
|
||||
criteria: z.any(),
|
||||
}),
|
||||
output: z.object({
|
||||
newGoalID: z.string(),
|
||||
changed: z.boolean(),
|
||||
}),
|
||||
},
|
||||
UPDATE_UGPT_SHOWCASE: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
|
||||
@@ -50,6 +50,10 @@ export function GetGoalIDsFromQuest(quest: QuestDocument) {
|
||||
export async function GetGoalsInQuest(quest: QuestDocument) {
|
||||
const goalIDs = GetGoalIDsFromQuest(quest);
|
||||
|
||||
if (goalIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const goals = await DB.selectFrom("goal")
|
||||
.select(SELECT_GOAL)
|
||||
.where("goal.id", "in", goalIDs)
|
||||
@@ -124,7 +128,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: QuestDocumen
|
||||
|
||||
const goalSubMap = new Map<string, GoalSubscriptionDocument>();
|
||||
|
||||
if (isSubscribedToQuest) {
|
||||
if (isSubscribedToQuest && goals.length > 0) {
|
||||
const goalSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
|
||||
@@ -382,16 +386,18 @@ export async function UnsubscribeFromQuest(
|
||||
.where("quest_sub.user_id", "=", questSub.userID)
|
||||
.execute();
|
||||
|
||||
const goalSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
|
||||
.where("goal_sub.user_id", "=", questSub.userID)
|
||||
.where("goal_sub.goal_id", "in", goalIDs)
|
||||
.execute();
|
||||
if (goalIDs.length > 0) {
|
||||
const goalSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
|
||||
.where("goal_sub.user_id", "=", questSub.userID)
|
||||
.where("goal_sub.goal_id", "in", goalIDs)
|
||||
.execute();
|
||||
|
||||
const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r));
|
||||
const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r));
|
||||
|
||||
await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
|
||||
await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
* promotes user #1 to admin so seed import can run.
|
||||
*/
|
||||
|
||||
import { buildChartIdMap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds";
|
||||
import { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds";
|
||||
|
||||
export { buildChartIdMap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset };
|
||||
export { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset };
|
||||
|
||||
// ── Standalone entrypoint ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ import type {
|
||||
MongoUserSettingsCollectionDocument,
|
||||
} from "./migrate-to-postgres.mongo-docs";
|
||||
|
||||
import { buildChartIdMap, importSeeds } from "./load-seeds-pg";
|
||||
import { buildChartIdMap, buildGoalIdRemap, importSeeds } from "./load-seeds-pg";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Connection setup
|
||||
@@ -400,6 +400,8 @@ async function main(): Promise<void> {
|
||||
// Used to resolve chart_id FK references in scores and PBs.
|
||||
const chartIdMap = buildChartIdMap(SEEDS_DIR);
|
||||
|
||||
const goalIdRemap = buildGoalIdRemap(SEEDS_DIR);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 0 - No FK dependencies
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1164,15 +1166,18 @@ async function main(): Promise<void> {
|
||||
console.log("\n[goal_sub]");
|
||||
const goalSubs = await mongoDB.get<MongoGoalSubsCollectionDocument>("goal-subs").find({});
|
||||
|
||||
const uniqueGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
|
||||
const uniqueMongoGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
|
||||
const translatedIds = uniqueMongoGoalIds.map((gid) => goalIdRemap.get(gid) ?? gid);
|
||||
const candidateGoalIdsForLookup = [...new Set([...uniqueMongoGoalIds, ...translatedIds])];
|
||||
|
||||
const existingGoalIds = new Set(
|
||||
uniqueGoalIds.length === 0
|
||||
candidateGoalIdsForLookup.length === 0
|
||||
? []
|
||||
: (
|
||||
await pg
|
||||
.selectFrom("goal")
|
||||
.select("id")
|
||||
.where("id", "in", uniqueGoalIds)
|
||||
.where("id", "in", candidateGoalIdsForLookup)
|
||||
.execute()
|
||||
).map((r) => r.id),
|
||||
);
|
||||
@@ -1180,13 +1185,17 @@ async function main(): Promise<void> {
|
||||
const goalSubRows: Array<NewGoalSub> = [];
|
||||
|
||||
for (const gs of goalSubs) {
|
||||
if (!existingGoalIds.has(gs.goalID)) {
|
||||
console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`);
|
||||
const resolvedGoalId = goalIdRemap.get(gs.goalID) ?? gs.goalID;
|
||||
|
||||
if (!existingGoalIds.has(resolvedGoalId)) {
|
||||
console.warn(
|
||||
` [goal_sub] Skipping - goal ${gs.goalID} (resolved=${resolvedGoalId}) not found in DB`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
goalSubRows.push({
|
||||
goal_id: gs.goalID,
|
||||
goal_id: resolvedGoalId,
|
||||
user_id: gs.userID,
|
||||
last_interaction: ts(gs.lastInteraction),
|
||||
progress: gs.progress,
|
||||
|
||||
+214
-3
@@ -1,5 +1,216 @@
|
||||
import { describe, it } from "vitest";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Game-group targets goals", () => {
|
||||
it.todo("port router.oldtest.ts");
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
async function seedGoalWithSub(chartId: string, userId: number) {
|
||||
const goalId = `G_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Test Goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("goal_sub")
|
||||
.values({
|
||||
goal_id: goalId,
|
||||
user_id: userId,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: null,
|
||||
progress_human: "NO DATA",
|
||||
out_of: 7,
|
||||
out_of_human: "FULL COMBO",
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
was_assigned_standalone: true,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return goalId;
|
||||
}
|
||||
|
||||
describe("GET /api/v1/games/:game/targets/goals/popular", () => {
|
||||
it("returns 200 with an array of goals", async () => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/popular");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the most-subscribed goals sorted by popularity", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const goalId = `G_pop_${Date.now()}`;
|
||||
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Popular Goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const { id: uid1 } = await seedUser({ username: `pop_u1_${Date.now()}` });
|
||||
const { id: uid2 } = await seedUser({ username: `pop_u2_${Date.now()}` });
|
||||
|
||||
for (const uid of [uid1, uid2]) {
|
||||
await DB.insertInto("goal_sub").values({
|
||||
goal_id: goalId,
|
||||
user_id: uid,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: null,
|
||||
progress_human: "NO DATA",
|
||||
out_of: 7,
|
||||
out_of_human: "FULL COMBO",
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
was_assigned_standalone: true,
|
||||
}).execute();
|
||||
}
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/popular");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const goalIds = (res.body.body as Array<{ goalID: string }>).map((g) => g.goalID);
|
||||
|
||||
expect(goalIds).toContain(goalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/games/:game/targets/goals/format", () => {
|
||||
it("returns a human-readable goal name for valid input", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/games/iidx-sp/targets/goals/format")
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(typeof res.body.body).toBe("string");
|
||||
expect(res.body.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid chart reference", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/games/iidx-sp/targets/goals/format")
|
||||
.send({
|
||||
charts: { type: "single", data: "C_does_not_exist" },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid criteria key", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/games/iidx-sp/targets/goals/format")
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "not_a_real_metric", value: 0 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/games/:game/targets/goals/:goalID", () => {
|
||||
it("returns goal + subscribers + parentQuests", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const { id: detailUserId } = await seedUser({ username: `goal_detail_${Date.now()}` });
|
||||
const goalId = await seedGoalWithSub(chartId, detailUserId);
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/goals/${goalId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.goal.goalID).toBe(goalId);
|
||||
expect(Array.isArray(res.body.body.goalSubs)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.users)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.parentQuests)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when the goal does not exist", async () => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/G_no_such_goal");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when the goal belongs to a different game", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const { id: gameCheckUserId } = await seedUser({ username: `goal_game_check_${Date.now()}` });
|
||||
const goalId = await seedGoalWithSub(chartId, gameCheckUserId);
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/sdvx/targets/goals/${goalId}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("parentQuestlines on quest detail returns proper QuestlineDocument shape", async () => {
|
||||
const suffix = `${Date.now()}`;
|
||||
const qlId = `ql-goal-test-${suffix}`;
|
||||
const qId = `q-goal-test-${suffix}`;
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const goalId = `G_ql_shape_${suffix}`;
|
||||
|
||||
await DB.insertInto("goal").values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Shape test goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("quest").values({
|
||||
id: qId,
|
||||
game: "iidx-sp",
|
||||
name: "Shape test quest",
|
||||
description: "d",
|
||||
quest_data: JSON.stringify([{ title: "s", goals: [{ goalID: goalId }] }]),
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("questline").values({
|
||||
id: qlId,
|
||||
game: "iidx-sp",
|
||||
name: "Shape test questline",
|
||||
description: "d",
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("questline_quest").values({
|
||||
questline_id: qlId,
|
||||
quest_id: qId,
|
||||
sort_order: 0,
|
||||
}).execute();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${qId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const ql = (res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }>)
|
||||
.find((x) => x.questlineID === qlId);
|
||||
|
||||
expect(ql).toBeDefined();
|
||||
expect(ql?.game).toBe("iidx-sp");
|
||||
expect(Array.isArray(ql?.quests)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+148
-3
@@ -1,5 +1,150 @@
|
||||
import { describe, it } from "vitest";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedMinimalIidxSpChart } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Game-group targets quests", () => {
|
||||
it.todo("port router.oldtest.ts");
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
async function seedQuest(suffix: string, goalId?: string) {
|
||||
const questId = `q-rt-${suffix}`;
|
||||
const questData = goalId
|
||||
? [{ title: "Section", goals: [{ goalID: goalId }] }]
|
||||
: [];
|
||||
|
||||
await DB.insertInto("quest")
|
||||
.values({
|
||||
id: questId,
|
||||
game: "iidx-sp",
|
||||
name: `Quest ${suffix}`,
|
||||
description: `Description for ${suffix}`,
|
||||
quest_data: JSON.stringify(questData),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return questId;
|
||||
}
|
||||
|
||||
describe("GET /api/v1/games/:game/targets/quests", () => {
|
||||
it("returns quests matching the search term", async () => {
|
||||
const suffix = `${Date.now()}`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/games/iidx-sp/targets/quests?search=Quest+${suffix}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const ids = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
|
||||
|
||||
expect(ids).toContain(questId);
|
||||
});
|
||||
|
||||
it("returns empty arrays when no quests match the search", async () => {
|
||||
const res = await mockApi.get(
|
||||
"/api/v1/games/iidx-sp/targets/quests?search=this_will_never_match_zzz_xyz",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.quests).toHaveLength(0);
|
||||
expect(res.body.body.goals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns associated goals in the response", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const goalId = `G_q_search_${Date.now()}`;
|
||||
const suffix = `${Date.now()}-g`;
|
||||
|
||||
await DB.insertInto("goal").values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Search goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
|
||||
}).execute();
|
||||
|
||||
const questId = await seedQuest(suffix, goalId);
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/games/iidx-sp/targets/quests?search=Quest+${suffix}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const questIds = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
|
||||
const goalIds = (res.body.body.goals as Array<{ goalID: string }>).map((g) => g.goalID);
|
||||
|
||||
expect(questIds).toContain(questId);
|
||||
expect(goalIds).toContain(goalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/games/:game/targets/quests/:questID", () => {
|
||||
it("returns quest + subscribers + goals + parentQuestlines", async () => {
|
||||
const suffix = `${Date.now()}-d`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${questId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.quest.questID).toBe(questId);
|
||||
expect(Array.isArray(res.body.body.questSubs)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.users)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.goals)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.parentQuestlines)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when quest does not exist", async () => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx-sp/targets/quests/q_no_such_quest");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when quest belongs to a different game", async () => {
|
||||
const suffix = `${Date.now()}-x`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/sdvx/targets/quests/${questId}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("parentQuestlines entries have V3Game shape (not legacy gameGroup+playtype)", async () => {
|
||||
const suffix = `${Date.now()}-v3`;
|
||||
const qlId = `ql-v3-${suffix}`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
await DB.insertInto("questline").values({
|
||||
id: qlId,
|
||||
game: "iidx-sp",
|
||||
name: "V3 test questline",
|
||||
description: "d",
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("questline_quest").values({
|
||||
questline_id: qlId,
|
||||
quest_id: questId,
|
||||
sort_order: 0,
|
||||
}).execute();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${questId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const ql = (
|
||||
res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }>
|
||||
).find((x) => x.questlineID === qlId);
|
||||
|
||||
expect(ql).toBeDefined();
|
||||
// game should be the V3Game string, not a legacy GameGroup
|
||||
expect(ql?.game).toBe("iidx-sp");
|
||||
// must NOT have a top-level playtype field
|
||||
expect((ql as any)?.playtype).toBeUndefined();
|
||||
// must carry the quests array
|
||||
expect(Array.isArray(ql?.quests)).toBe(true);
|
||||
expect(ql?.quests).toContain(questId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LoadPbsOnChartForUserSearch,
|
||||
} from "#lib/db-formats/pb";
|
||||
import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest";
|
||||
import { SELECT_QUESTLINE_ROW } from "#lib/db-formats/questline";
|
||||
import { GetSongByID, GetSongsByIDs } from "#lib/db-formats/song";
|
||||
import {
|
||||
GetTableDocumentsForGame,
|
||||
@@ -54,7 +53,11 @@ import {
|
||||
} from "#utils/db";
|
||||
import { EscapeForILIKE, IsString } from "#utils/misc";
|
||||
import { FindChartsOnPopularity } from "#utils/queries/charts";
|
||||
import { GetQuestlineById, GetQuestlinesForGame } from "#utils/queries/questlines";
|
||||
import {
|
||||
GetQuestlineById,
|
||||
GetQuestlinesForGame,
|
||||
GetQuestlinesThatContainQuest,
|
||||
} from "#utils/queries/questlines";
|
||||
import {
|
||||
CheckStrProfileAlg,
|
||||
CheckStrScoreAlg,
|
||||
@@ -673,9 +676,11 @@ API_V1_ROUTER.add("GET /games/:game/targets/recently-raised", withGame, async ({
|
||||
* @name GET /api/v1/games/:game/targets/goals/popular
|
||||
*/
|
||||
API_V1_ROUTER.add("GET /games/:game/targets/goals/popular", withGame, async ({ ctx }) => {
|
||||
const { gameGroup: _gameGroup, playtype } = LEGACY_GameToGameGroupPT(ctx.game);
|
||||
const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(ctx.game);
|
||||
|
||||
const goals = await GetMostSubscribedGoals({ game: ctx.game, playtype });
|
||||
// GetMostSubscribedGoals internally converts GameGroup+playtype to V3Game for filtering
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const goals = await GetMostSubscribedGoals({ game: gameGroup as any, playtype });
|
||||
|
||||
return success(`Returned ${goals.length} goals.`, goals);
|
||||
});
|
||||
@@ -800,23 +805,7 @@ API_V1_ROUTER.add("GET /games/:game/targets/quests/:questID", withGame, async ({
|
||||
const users = await GetUsersWithIDs(questSubs.map((e) => e.userID));
|
||||
const goals = await GetGoalsInQuest(quest);
|
||||
|
||||
const qlRows = await DB.selectFrom("questline")
|
||||
.innerJoin("questline_quest", "questline_quest.questline_id", "questline.id")
|
||||
.select(SELECT_QUESTLINE_ROW)
|
||||
.where("questline_quest.quest_id", "=", quest.questID)
|
||||
.execute();
|
||||
|
||||
const parentQuestlines = qlRows.map((ql) => {
|
||||
const { gameGroup: gg, playtype: pt } = LEGACY_GameToGameGroupPT(ql.game);
|
||||
|
||||
return {
|
||||
desc: ql.description,
|
||||
game: gg,
|
||||
name: ql.name,
|
||||
playtype: pt,
|
||||
questlineID: ql.id,
|
||||
};
|
||||
});
|
||||
const parentQuestlines = await GetQuestlinesThatContainQuest(quest.questID);
|
||||
|
||||
return success(`Retrieved information about ${quest.name}.`, {
|
||||
goals,
|
||||
|
||||
@@ -1174,6 +1174,19 @@ export const API_V1_SPEC = {
|
||||
}),
|
||||
},
|
||||
|
||||
"PUT /users/:userID/games/:game/targets/goals/:goalID": {
|
||||
description: "Update a standalone goal subscription by replacing its definition.",
|
||||
input: z.object({
|
||||
criteria: z.unknown(),
|
||||
charts: z.unknown(),
|
||||
}),
|
||||
output: z.strictObject({
|
||||
changed: z.boolean(),
|
||||
goal: doc<GoalDocument>(),
|
||||
goalSub: doc<GoalSubscriptionDocument>(),
|
||||
}),
|
||||
},
|
||||
|
||||
"DELETE /users/:userID/games/:game/targets/goals/:goalID": {
|
||||
description: "Unsubscribe from a goal.",
|
||||
input: z.object({}),
|
||||
@@ -1187,7 +1200,7 @@ export const API_V1_SPEC = {
|
||||
output: z.strictObject({
|
||||
questSubs: docArray<QuestSubscriptionDocument>(),
|
||||
quests: docArray<QuestDocument>(),
|
||||
goals: z.record(z.string(), docArray<GoalDocument>()),
|
||||
goals: docArray<GoalDocument>(),
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
+397
-5
@@ -1,7 +1,399 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { newGameProfilePreferenceColumns } from "#lib/game-settings/create-game-settings";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("UGPT targets goals", () => {
|
||||
it.todo(
|
||||
"port large router.oldtest.ts (GET goals, POST add-goal, …) with Postgres goal/goal_sub",
|
||||
);
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username,
|
||||
"!password": password,
|
||||
captcha: "test",
|
||||
});
|
||||
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
async function seedGameProfile(userId: number) {
|
||||
await DB.insertInto("game_profile")
|
||||
.values({
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
ratings: JSON.stringify({}),
|
||||
classes: JSON.stringify({}),
|
||||
...newGameProfilePreferenceColumns("iidx-sp"),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ─── GET goals ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/targets/goals", () => {
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
({ id: userId } = await seedUser({
|
||||
username: `goals_get_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 200 with empty arrays when user has no goals", async () => {
|
||||
const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/goals`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.goals).toHaveLength(0);
|
||||
expect(res.body.body.goalSubs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the user's subscribed goals when they have some", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const goalId = `G_get_${Date.now()}`;
|
||||
|
||||
await DB.insertInto("goal").values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Listed goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("goal_sub").values({
|
||||
goal_id: goalId,
|
||||
user_id: userId,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: null,
|
||||
progress_human: "NO DATA",
|
||||
out_of: 7,
|
||||
out_of_human: "FULL COMBO",
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
was_assigned_standalone: true,
|
||||
}).execute();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/goals`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.goalSubs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST add-goal ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/v1/users/:userID/games/:game/targets/goals/add-goal", () => {
|
||||
let userId: number;
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const { username } = await seedUser({
|
||||
username: `goals_add_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
// seedUser returns the inserted id but we need to look it up since id is serial
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("account.id")
|
||||
.where("account.username", "=", username)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
userId = Number(row.id);
|
||||
cookie = await loginAs(username);
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 and creates the goal + sub when input is valid", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.goal.game).toBe("iidx-sp");
|
||||
expect(res.body.body.goalSub.userID).toBe(userId);
|
||||
});
|
||||
|
||||
it("returns 409 when the user is already subscribed to that goal", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const body = {
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
};
|
||||
|
||||
await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send(body);
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send(body);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET single goal ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
({ id: userId } = await seedUser({ username: `goals_single_${suffix}` }));
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 404 when user is not subscribed to the goal", async () => {
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_no_such`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the goal sub when subscribed", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const goalId = `G_single_${Date.now()}`;
|
||||
|
||||
await DB.insertInto("goal").values({
|
||||
id: goalId,
|
||||
game: "iidx-sp",
|
||||
name: "Single test goal",
|
||||
charts: JSON.stringify({ type: "single", data: chartId }),
|
||||
criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
|
||||
}).execute();
|
||||
|
||||
await DB.insertInto("goal_sub").values({
|
||||
goal_id: goalId,
|
||||
user_id: userId,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: null,
|
||||
progress_human: "NO DATA",
|
||||
out_of: 4,
|
||||
out_of_human: "HARD CLEAR",
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
was_assigned_standalone: true,
|
||||
}).execute();
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalId}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.goal.goalID).toBe(goalId);
|
||||
expect(res.body.body.goalSub.userID).toBe(userId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PUT update goal ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
|
||||
let userId: number;
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const { username } = await seedUser({
|
||||
username: `goals_put_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("account.id")
|
||||
.where("account.username", "=", username)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
userId = Number(row.id);
|
||||
cookie = await loginAs(username);
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_fake`)
|
||||
.send({
|
||||
charts: { type: "single", data: "C_fake" },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when not subscribed to the old goal", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const res = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_nonexistent`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 7 },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("swaps the goal subscription and returns the new goal", async () => {
|
||||
const chartA = await seedMinimalIidxSpChart();
|
||||
const chartB = await seedMinimalIidxSpChart();
|
||||
|
||||
// Add initial goal via the add-goal endpoint
|
||||
const addRes = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartA },
|
||||
criteria: { mode: "single", key: "lamp", value: 4 },
|
||||
});
|
||||
|
||||
expect(addRes.status).toBe(200);
|
||||
|
||||
const oldGoalID = addRes.body.body.goal.goalID as string;
|
||||
|
||||
// Update to a different chart
|
||||
const putRes = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${oldGoalID}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartB },
|
||||
criteria: { mode: "single", key: "lamp", value: 4 },
|
||||
});
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.success).toBe(true);
|
||||
expect(putRes.body.body.changed).toBe(true);
|
||||
expect(putRes.body.body.goal.charts).toMatchObject({ type: "single", data: chartB });
|
||||
expect(putRes.body.body.goalSub.userID).toBe(userId);
|
||||
|
||||
// Old sub should be gone
|
||||
const oldSub = await DB.selectFrom("goal_sub")
|
||||
.selectAll()
|
||||
.where("goal_sub.goal_id", "=", oldGoalID)
|
||||
.where("goal_sub.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(oldSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns changed: false when the definition is unchanged", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const addRes = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 4 },
|
||||
});
|
||||
|
||||
const goalID = addRes.body.body.goal.goalID as string;
|
||||
|
||||
const putRes = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalID}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 4 },
|
||||
});
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.body.changed).toBe(false);
|
||||
expect(putRes.body.body.goal.goalID).toBe(goalID);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE goal ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
|
||||
let userId: number;
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const { username } = await seedUser({
|
||||
username: `goals_del_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("account.id")
|
||||
.where("account.username", "=", username)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
userId = Number(row.id);
|
||||
cookie = await loginAs(username);
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.delete(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_fake`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("removes the goal sub and returns 200", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
|
||||
const addRes = await mockApi
|
||||
.post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { mode: "single", key: "lamp", value: 4 },
|
||||
});
|
||||
|
||||
const goalID = addRes.body.body.goal.goalID as string;
|
||||
|
||||
const delRes = await mockApi
|
||||
.delete(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalID}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const sub = await DB.selectFrom("goal_sub")
|
||||
.selectAll()
|
||||
.where("goal_sub.goal_id", "=", goalID)
|
||||
.where("goal_sub.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(sub).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+50
@@ -144,6 +144,56 @@ API_V1_ROUTER.add(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a standalone goal subscription by replacing its definition.
|
||||
*
|
||||
* Creates a new goal from the provided charts/criteria and atomically swaps
|
||||
* the user's subscription from the old goal to the new one. Progress is
|
||||
* re-evaluated from scores on the next import — no migration is needed.
|
||||
*
|
||||
* Only standalone goals (not quest-assigned goals) may be updated this way.
|
||||
*
|
||||
* @name PUT /api/v1/users/:userID/games/:game/targets/goals/:goalID
|
||||
*/
|
||||
API_V1_ROUTER.add(
|
||||
"PUT /users/:userID/games/:game/targets/goals/:goalID",
|
||||
withUserGameProfile,
|
||||
async ({ ctx, params, input, req }) => {
|
||||
const { requestedUser: user, game } = ctx;
|
||||
|
||||
const sessionUser = req.session.tachi?.user;
|
||||
|
||||
if (!sessionUser) {
|
||||
throw new ExpectedErr(401, "You are not authenticated.");
|
||||
}
|
||||
|
||||
const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip };
|
||||
|
||||
const { ACTION_UpdateGoalSubscription } = await import(
|
||||
"#actions/update-goal-subscription"
|
||||
);
|
||||
const { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } = await import(
|
||||
"#utils/db"
|
||||
);
|
||||
|
||||
const { newGoalID, changed } = await ACTION_UpdateGoalSubscription(taker, {
|
||||
charts: input.charts as GoalDocument["charts"],
|
||||
criteria: input.criteria as GoalDocument["criteria"],
|
||||
game,
|
||||
oldGoalID: params.goalID,
|
||||
userID: user.id,
|
||||
});
|
||||
|
||||
const newGoal = await GetGoalForIDGuaranteed(newGoalID);
|
||||
const newGoalSub = await GetGoalSubscriptionForIDGuaranteed(newGoalID, user.id);
|
||||
|
||||
return success(
|
||||
changed ? `Updated goal to '${newGoal.name}'.` : "Goal definition is unchanged.",
|
||||
{ changed, goal: newGoal, goalSub: newGoalSub },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unsubscribe from a goal.
|
||||
*
|
||||
|
||||
+304
-3
@@ -1,5 +1,306 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { newGameProfilePreferenceColumns } from "#lib/game-settings/create-game-settings";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("UGPT targets quests", () => {
|
||||
it.todo("port router.oldtest.ts (GET/PATCH quest routes) with Postgres quest/quest_sub");
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username,
|
||||
"!password": password,
|
||||
captcha: "test",
|
||||
});
|
||||
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
async function seedGameProfile(userId: number) {
|
||||
await DB.insertInto("game_profile")
|
||||
.values({
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
ratings: JSON.stringify({}),
|
||||
classes: JSON.stringify({}),
|
||||
...newGameProfilePreferenceColumns("iidx-sp"),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function seedQuest(suffix: string) {
|
||||
const questId = `q-ugpt-${suffix}`;
|
||||
|
||||
await DB.insertInto("quest")
|
||||
.values({
|
||||
id: questId,
|
||||
game: "iidx-sp",
|
||||
name: `UGPT Quest ${suffix}`,
|
||||
description: "Test quest",
|
||||
quest_data: JSON.stringify([]),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return questId;
|
||||
}
|
||||
|
||||
// ─── GET quests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/targets/quests", () => {
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
({ id: userId } = await seedUser({ username: `q_get_${suffix}` }));
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 200 with empty arrays when user has no quest subscriptions", async () => {
|
||||
const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/quests`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.quests).toHaveLength(0);
|
||||
expect(res.body.body.questSubs).toHaveLength(0);
|
||||
expect(res.body.body.goals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns subscribed quests and their goals", async () => {
|
||||
const suffix = `${Date.now()}`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
await DB.insertInto("quest_sub")
|
||||
.values({
|
||||
quest_id: questId,
|
||||
user_id: userId,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: 0,
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/quests`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const questIds = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
|
||||
|
||||
expect(questIds).toContain(questId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET single quest ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
({ id: userId } = await seedUser({ username: `q_single_${suffix}` }));
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 404 when user is not subscribed", async () => {
|
||||
const suffix = `${Date.now()}-ns`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when quest does not exist", async () => {
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/quests/q_no_such`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns quest progress when subscribed", async () => {
|
||||
const suffix = `${Date.now()}-sub`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
await DB.insertInto("quest_sub")
|
||||
.values({
|
||||
quest_id: questId,
|
||||
user_id: userId,
|
||||
achieved: false,
|
||||
time_achieved: null,
|
||||
progress: 0,
|
||||
last_interaction: null,
|
||||
was_instantly_achieved: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.quest.questID).toBe(questId);
|
||||
expect(res.body.body.questSub.userID).toBe(userId);
|
||||
expect(Array.isArray(res.body.body.goals)).toBe(true);
|
||||
expect(Array.isArray(res.body.body.results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PUT subscribe ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
|
||||
let userId: number;
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const { username } = await seedUser({
|
||||
username: `q_put_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("account.id")
|
||||
.where("account.username", "=", username)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
userId = Number(row.id);
|
||||
cookie = await loginAs(username);
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const suffix = `${Date.now()}-auth`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.put(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when quest does not exist", async () => {
|
||||
const res = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/q_no_such`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("subscribes the user to the quest and returns 200", async () => {
|
||||
const suffix = `${Date.now()}-ok`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.quest.questID).toBe(questId);
|
||||
|
||||
const sub = await DB.selectFrom("quest_sub")
|
||||
.selectAll()
|
||||
.where("quest_sub.quest_id", "=", questId)
|
||||
.where("quest_sub.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(sub).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 409 with 'quest' (not 'goal') when already subscribed", async () => {
|
||||
const suffix = `${Date.now()}-dup`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
const res = await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
// The error message must mention "quest", not "goal" (regression guard for the bug fix)
|
||||
expect(res.body.description).toMatch(/quest/i);
|
||||
expect(res.body.description).not.toMatch(/goal/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE unsubscribe ───────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
|
||||
let userId: number;
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const { username } = await seedUser({
|
||||
username: `q_del_${suffix}`,
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("account.id")
|
||||
.where("account.username", "=", username)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
userId = Number(row.id);
|
||||
cookie = await loginAs(username);
|
||||
await seedGameProfile(userId);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const suffix = `${Date.now()}-dauth`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi.delete(
|
||||
`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 409 when not subscribed", async () => {
|
||||
const suffix = `${Date.now()}-dns`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("removes the quest sub and returns 200", async () => {
|
||||
const suffix = `${Date.now()}-dok`;
|
||||
const questId = await seedQuest(suffix);
|
||||
|
||||
// Subscribe first
|
||||
await mockApi
|
||||
.put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
const delRes = await mockApi
|
||||
.delete(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const sub = await DB.selectFrom("quest_sub")
|
||||
.selectAll()
|
||||
.where("quest_sub.quest_id", "=", questId)
|
||||
.where("quest_sub.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(sub).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+11
-3
@@ -102,9 +102,13 @@ API_V1_ROUTER.add(
|
||||
API_V1_ROUTER.add(
|
||||
"PUT /users/:userID/games/:game/targets/quests/:questID",
|
||||
withUserGameProfile,
|
||||
async ({ ctx, params }) => {
|
||||
async ({ ctx, params, req }) => {
|
||||
const { requestedUser: user, game } = ctx;
|
||||
|
||||
if (!req.session.tachi?.user) {
|
||||
throw new ExpectedErr(401, "You are not authenticated.");
|
||||
}
|
||||
|
||||
const { SubscribeFailReasons } = await import("#lib/constants/err-codes");
|
||||
const { SubscribeToQuest } = await import("#lib/targets/quests");
|
||||
const { ServerConfig } = await import("#lib/setup/config");
|
||||
@@ -146,7 +150,7 @@ API_V1_ROUTER.add(
|
||||
.executeTakeFirst();
|
||||
|
||||
if (alreadySubscribed) {
|
||||
throw new ExpectedErr(409, "You are already subscribed to this goal.");
|
||||
throw new ExpectedErr(409, "You are already subscribed to this quest.");
|
||||
}
|
||||
|
||||
const subResult = await SubscribeToQuest(user.id, quest, false);
|
||||
@@ -167,9 +171,13 @@ API_V1_ROUTER.add(
|
||||
API_V1_ROUTER.add(
|
||||
"DELETE /users/:userID/games/:game/targets/quests/:questID",
|
||||
withUserGameProfile,
|
||||
async ({ ctx, params }) => {
|
||||
async ({ ctx, params, req }) => {
|
||||
const { requestedUser: user, game } = ctx;
|
||||
|
||||
if (!req.session.tachi?.user) {
|
||||
throw new ExpectedErr(401, "You are not authenticated.");
|
||||
}
|
||||
|
||||
const { UnsubscribeFromQuest } = await import("#lib/targets/quests");
|
||||
|
||||
const questRow = await DB.selectFrom("quest")
|
||||
|
||||
@@ -296,6 +296,22 @@ export function buildChartIdMap(seedsDir: string): Map<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional map from `rerunners/v3/7-remap-goals-folder-and-chart-ids.ts` (`db/seeds/goal-id-remap.json`).
|
||||
* Mongo `goal-subs.goalID` may still use hashes from before legacy chart/folder ids were rewired.
|
||||
*/
|
||||
export function buildGoalIdRemap(seedsDir: string): Map<string, string> {
|
||||
const p = path.join(seedsDir, "goal-id-remap.json");
|
||||
|
||||
if (!fs.existsSync(p)) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf-8")) as Record<string, string>;
|
||||
|
||||
return new Map(Object.entries(raw));
|
||||
}
|
||||
|
||||
// ── Core import logic ──────────────────────────────────────────────────────
|
||||
|
||||
export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promise<void> {
|
||||
|
||||
@@ -40,6 +40,51 @@ export async function GetQuestlinesForGame(game: V3Game): Promise<Array<Questlin
|
||||
return qlRows.map((row) => ToQuestlineDocument(row, byQuestline.get(row.questline_id) ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
* All questlines that contain a given quest, with `quests` ordered by `sort_order`.
|
||||
*/
|
||||
export async function GetQuestlinesThatContainQuest(
|
||||
questID: string,
|
||||
): Promise<Array<QuestlineDocument>> {
|
||||
const memberRows = await DB.selectFrom("questline_quest")
|
||||
.select("questline_quest.questline_id")
|
||||
.where("questline_quest.quest_id", "=", questID)
|
||||
.execute();
|
||||
|
||||
if (memberRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const questlineIds = memberRows.map((r) => r.questline_id);
|
||||
|
||||
const qlRows = await DB.selectFrom("questline")
|
||||
.select(SELECT_QUESTLINE)
|
||||
.where("questline.id", "in", questlineIds)
|
||||
.execute();
|
||||
|
||||
const qqRows = await DB.selectFrom("questline_quest")
|
||||
.select([
|
||||
"questline_quest.questline_id",
|
||||
"questline_quest.quest_id",
|
||||
"questline_quest.sort_order",
|
||||
])
|
||||
.where("questline_quest.questline_id", "in", questlineIds)
|
||||
.orderBy("questline_quest.questline_id")
|
||||
.orderBy("questline_quest.sort_order")
|
||||
.execute();
|
||||
|
||||
const byQuestline = new Map<string, Array<string>>();
|
||||
|
||||
for (const row of qqRows) {
|
||||
const list = byQuestline.get(row.questline_id) ?? [];
|
||||
|
||||
list.push(row.quest_id);
|
||||
byQuestline.set(row.questline_id, list);
|
||||
}
|
||||
|
||||
return qlRows.map((row) => ToQuestlineDocument(row, byQuestline.get(row.questline_id) ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
* One questline by id, scoped to game. `quests` are ordered by `questline_quest.sort_order`.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user