feat: a start on goals/quests

This commit is contained in:
zk
2026-05-16 16:51:20 +00:00
parent 0dcd5325b6
commit 8a125634a8
33 changed files with 4870 additions and 1505 deletions
+9 -38
View File
@@ -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)
+233
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+232 -232
View File
File diff suppressed because it is too large Load Diff
@@ -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} />;
}
@@ -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 &amp; 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}`;
}
}
}
+15
View File
@@ -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}`);
}
+5
View File
@@ -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(),
+15 -9
View File
@@ -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,
@@ -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);
});
});
@@ -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>(),
}),
},
@@ -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();
});
});
@@ -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.
*
@@ -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();
});
});
@@ -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`.
*/