mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-05 05:18:12 +03:00
feat: first overhaul
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# About
|
||||
|
||||
As you'd expect, this section contains implementation details. Although the entire
|
||||
codebase reference is technically about implementation details, this part is
|
||||
for miscellaneous implementation details that wouldn't fit anywhere else.
|
||||
|
||||
*****
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Game Configuration
|
||||
|
||||
Tachi has two sets of game configurations.
|
||||
|
||||
The first is on the game level, which contains things
|
||||
like the humanised name for the game (i.e. `iidx -> beatmania IIDX`).
|
||||
|
||||
The second is for each individual game + playtype
|
||||
combination, which contains things like the list of
|
||||
lamps for the game.
|
||||
|
||||
!!! help
|
||||
This page is unfinished.
|
||||
|
||||
It would be really appreciated if someone formatted
|
||||
the configurations for every game! For the time being,
|
||||
the below documentation is just the raw configuration
|
||||
for each game. Please see [Contributing to Tachi](../../contributing).
|
||||
|
||||
*****
|
||||
|
||||
This documentation is unfinished. It is probably easier for you to [just read the config.ts file](https://github.com/TNG-dev/Tachi/tree/staging/common/src/config/config.ts)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Goal ID implementation
|
||||
|
||||
Goal IDs exist to dedupe goals when a user creates
|
||||
a new goal. For example, if a user wants to create a
|
||||
HARD CLEAR Mei goal, but one already exists, we should
|
||||
not insert two [Goal Documents](../../schemas/goal.md)
|
||||
representing the same thing.
|
||||
|
||||
*****
|
||||
|
||||
## Hashing
|
||||
|
||||
To create the Goal ID, we perform a [json stable hash](https://github.com/zkldi/fast-json-stable-hash) on the
|
||||
`game`, `playtype`, `charts` and `criteria` of this field.
|
||||
|
||||
This enforces that goals are unique on those fields.
|
||||
|
||||
The above hash is then returned, prefixed with `G`.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Goals, Quests, Questlines
|
||||
|
||||
This page covers pretty much everything you need to know about interacting with goals
|
||||
and quests.
|
||||
|
||||
Although these features are fairly straightforward on the surface, their implementation is a bit complex at points. We'll get to it.
|
||||
|
||||
## What is a Goal?
|
||||
|
||||
A goal is a set of instructions that are evaluated for a player if they are subscribed to it (and it is relevant to their current import, see [Getting Relevant Goals](../import/goals.md)).
|
||||
|
||||
!!! example
|
||||
An example goal would be something like "AAA 100 charts in the X Folder".
|
||||
|
||||
Users must subscribe to goals for them to be evaluated when they import scores. This is
|
||||
abstracted away from the user, as the operation to create a goal will automatically subscribe
|
||||
them to the same goal.
|
||||
|
||||
### Identification
|
||||
|
||||
Goals are identified by a checksum of their instructions. If two users make the goal to "AAA Freedom Dive", the goalID will be the same.
|
||||
|
||||
This allows us to do things like see what goals are popular, and reduce the general duplication on the database.
|
||||
|
||||
## User Subscriptions
|
||||
|
||||
Users may create and subscribe to goals. This is done with the [UGPT Create Goals Endpoint](TODO). If they create a goal that already exists, they are just subscribed to that goal.
|
||||
|
||||
If they create a goal that does not already exist, it is created, and they are subscribed.
|
||||
|
||||
Users may unsubscribe from goals that they no longer care about getting pinged for.
|
||||
|
||||
Subscriptions to goals are stored in a [GoalSubscriptionDocument](../../schemas/goal-sub.md).
|
||||
This document is uniquely identified by the joining of the `goalID` with the `userID`.
|
||||
|
||||
### Instant Direct Achievements
|
||||
|
||||
If a user creates/subscribes to a goal that they would instantly achieve, they are denied
|
||||
from adding goal directly.
|
||||
|
||||
### Evaluating A User's Progress
|
||||
|
||||
Goals have two main components in terms of evaluation -- `progress` and `outOf`. If the progress is greater than or equal to the `outOf`, the goal is marked as achieved.
|
||||
|
||||
Both of these values are numbers, and are not intended for human consumption. For example, the goal 'HARD CLEAR Freedom Dive' would be represented by an `outOf` of `6`, which is the internal lampIndex for a BMS HARD CLEAR.
|
||||
|
||||
For human representation, these values are formatted and stored in the Goal Subscription Document as `progressHuman` and `outOfHuman`, respectively. These values are prettified for human users, and leverage game-specific formatting in some cases.
|
||||
|
||||
!!! note
|
||||
Achieved goals are still calculated -- a user may actually find themselves un-achieving goals in some stranger cases.
|
||||
|
||||
If their goal is something like AAA 10% of a folder, and the folder gets larger, they
|
||||
may find themselves losing the achieved status on this goal.
|
||||
|
||||
!!! warning
|
||||
Goal Subscriptions are capped by the MAX_GOAL_SUBSCRIPTIONS [conf.json5](../setup/config.md) property, and defaults to 1000.
|
||||
|
||||
!!! danger
|
||||
Unsubscribing from goals directly is only possible if they do not have any parent quests.
|
||||
More on that below.
|
||||
|
||||
## What is a Quest?
|
||||
|
||||
Quests are groups of goals, defined in a structured form, with support for assigning
|
||||
notes next to goalIDs, and other generally useful stuff.
|
||||
|
||||
Their purpose for users is to give them a quick way to assign a bunch of goals they might care about.
|
||||
|
||||
For example, a dedicated user may make their own "Kaiden Checklist", and create a set of goals
|
||||
they think are useful for kaidens to aim for. Another user interested in this can just
|
||||
subscribe to that quest, and all the goals will be managed for them.
|
||||
|
||||
### Identification
|
||||
|
||||
Quests are identified by their `questID`, which is a completely arbitrary string.
|
||||
|
||||
At the moment, users cannot create quests directly, they are hard-defined by the [Database Seeds](../infrastructure/database-seeds.md).
|
||||
|
||||
### Evaluating a User's Progress
|
||||
|
||||
Similarly to goals, user's can subscribe to quests.
|
||||
|
||||
Quest subscriptions are interesting in that the `progress` factor is always an integer -- how many goals in the quest the user has achieved, and the `outOf` factor is always an integer -- how many goals need to be achieved in the quest for the quest to be marked as achieved.
|
||||
|
||||
Similarly, the `achieved` status on quests is identical to `progress` being greater than or equal to `outOf`.
|
||||
|
||||
### Parenting Goal Subscriptions
|
||||
|
||||
When a quest is subscribed to, all the goals in the quest are also subscribed to.
|
||||
|
||||
When a user is subscribed to a quest, they *must* also be subscribed to all the goals in
|
||||
that quest. If they aren't, they've desynced with the quest, and that's an awful
|
||||
user experience.
|
||||
|
||||
As such, Tachi keeps track of all the quests that care about this goal subscription, and users are *prevented from unsubscribing from this goal* while any of their quest subscriptions parent the goal.
|
||||
|
||||
### Instant Indirect Achievements
|
||||
|
||||
Subscribing to a quest *mandates* that all of the goals are assigned. It is not rare for this to mean that you subscribe to goals that are instantly achieved.
|
||||
|
||||
This is fine. If this does happen though, `wasInstantlyAchieved` is set on the goal, and no
|
||||
webhook event is emitted.
|
||||
|
||||
### Unsubscribing
|
||||
|
||||
For all `goalID`s in the quest, we check if this goal subscription has any other parent quests.
|
||||
|
||||
If this quest was the only reason the goal was assigned (i.e. it wasn't assigned directly, or isn't part of another quest), this quest will be unsubscribed from.
|
||||
|
||||
## Questlines
|
||||
|
||||
Questline are ordered lists of quests. Their purpose is to group quests together
|
||||
visually in an ordered manner.
|
||||
|
||||
Users *can not* "subscribe" to questlines, but they can use questlines as a utility for subscribing to all the related quests.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Score ID implementation
|
||||
|
||||
Score IDs exist to dedupe scores when a user re-submits
|
||||
the same scores. This happens frequently with `file/`
|
||||
and `api/` [Import Types](../import/import-types.md),
|
||||
as they typically resubmit the same scores.
|
||||
|
||||
If a user only got a score once, we don't want to store
|
||||
it twice.
|
||||
|
||||
Sadly, we can't depend on things like timestamps to assert
|
||||
whether or whether not we've saw a score before. Many services
|
||||
alter/tamper their timestamps such that they're unreliable.
|
||||
|
||||
!!! example
|
||||
E-Amusement IIDX CSVs will change the timestamp of every single
|
||||
score when a new version comes out to the second the user created
|
||||
their new account.
|
||||
|
||||
*****
|
||||
|
||||
## Hashing
|
||||
|
||||
The score ID is created by joining the following properties:
|
||||
|
||||
- The `userID` that got this score
|
||||
- The `chartID` that this score was on
|
||||
- All [Provided Metrics](todo) for this GPT
|
||||
- Any [Optional Metrics](todo) that have been marked as `partOfScoreID`
|
||||
|
||||
and then hashing them with SHA256.
|
||||
|
||||
This is then prefixed with `T`, and returned.
|
||||
|
||||
## Clobbering
|
||||
|
||||
Since a scoreID isn't necessarily all of the possible statistics for a score, it's
|
||||
possible for users to "clobber" their scores, by importing a score without as many
|
||||
pieces of info (i.e. no judgements), then trying to import that same score with
|
||||
judgements later will not work, as the scoreID sees it as a duplicate.
|
||||
|
||||
See [Clobbering](todo).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Search Implementation
|
||||
|
||||
Tachi's search implementation uses MongoDB's $text index. This breaks a query into words
|
||||
and compares each of them to the provided text fields.
|
||||
|
||||
*****
|
||||
|
||||
## __textScore
|
||||
|
||||
For our code, we mutate the documents we want to return with a special field: `__textScore`.
|
||||
|
||||
This field declares how 'close' the provided query was to the $text fields in this document.
|
||||
|
||||
This is sometimes exposed in the API for sorting reasons.
|
||||
|
||||
!!! bug
|
||||
MongoDB's $text matching algorithm isn't great for fuzzy matches - It doesn't
|
||||
like song titles like 'A', as it thinks 'a' is an article, and doesn't match it properly as
|
||||
a word.
|
||||
|
||||
!!! info
|
||||
Why not regex for fuzzy matches?
|
||||
|
||||
Regex has performance issues on larger datasets and we
|
||||
want to avoid it. Most regexes cannot use indexes, and therefore invoke a COLLSCAN, which
|
||||
we want to avoid.
|
||||
|
||||
## User Searching
|
||||
|
||||
Searching users, on the other hand, has to use regex-based
|
||||
searching.
|
||||
|
||||
The `$text` method attempts to break things up based on their
|
||||
words, but that doesn't help with usernames, as they are all
|
||||
too frequently `XxX_One_Long_Str1ng_xXx`.
|
||||
|
||||
Instead, we use a case insensitive regex - similar to SQL's
|
||||
`LIKE`.
|
||||
|
||||
This means we do not have a `__textScore` property for this
|
||||
search to sort on. Instead, we just constrict returns to
|
||||
around 15, and have the user whittle their search down
|
||||
better.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Songs And Charts
|
||||
|
||||
Tachi structures its song and chart data in a specific
|
||||
manner in order to avoid copying properties all over
|
||||
the place.
|
||||
|
||||
*****
|
||||
|
||||
## Songs
|
||||
|
||||
Songs look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "5.1.1.",
|
||||
"artist": "dj nagureo",
|
||||
"id": 1,
|
||||
"firstVersion": "0",
|
||||
"alt-titles": [],
|
||||
"search-titles": [],
|
||||
"data": {
|
||||
"genre": "PIANO AMBIENT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
They're relatively small documents, and contain some metadata.
|
||||
|
||||
Depending on the game, the `data` prop will contain
|
||||
game-specific properties (i.e. not all games have
|
||||
song genres!)
|
||||
|
||||
Songs, however, don't contain any information about
|
||||
their *charts*. The charts are what people actually play.
|
||||
|
||||
In short, songs are *just* a collection of metadata that
|
||||
parents a chart document!
|
||||
|
||||
*****
|
||||
|
||||
## Chart Documents
|
||||
|
||||
Chart Documents **MUST** belong to a song. In the below
|
||||
chart, `songID` refers to the above song document:
|
||||
|
||||
```json
|
||||
{
|
||||
"rgcID": null,
|
||||
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
|
||||
"difficulty": "ANOTHER",
|
||||
"songID": 1,
|
||||
"playtype": "SP",
|
||||
"levelNum": 10,
|
||||
"level": "10",
|
||||
"flags": {
|
||||
"IN BASE GAME": true,
|
||||
"OMNIMIX": true,
|
||||
"N-1": true,
|
||||
"2dxtra": false
|
||||
},
|
||||
"data": {
|
||||
"inGameID": 1000,
|
||||
"notecount": 786,
|
||||
},
|
||||
"isPrimary": true,
|
||||
"versions": [
|
||||
"27-omni",
|
||||
"26-omni",
|
||||
"27",
|
||||
"26",
|
||||
"inf",
|
||||
"16-cs",
|
||||
"12-cs",
|
||||
"10-cs",
|
||||
"8-cs",
|
||||
"7-cs",
|
||||
"bmus"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Songs can have multiple charts, but charts can only have
|
||||
one song.
|
||||
|
||||
## Primary
|
||||
|
||||
Sometimes, games like to rechart things and release them
|
||||
under the exact same song. Sometimes, they even do this
|
||||
under the *exact* same internal songID!
|
||||
|
||||
A 'Primary' chart refers to a chart that is the *current*
|
||||
variant of that chart for this game. Non-Primary charts
|
||||
are not eligible for any rating calculations, nor do they
|
||||
contribute to profile rating.
|
||||
|
||||
To check if a chart is primary or not, the `isPrimary` property
|
||||
handles that.
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"rgcID": null,
|
||||
"chartID": "103ff8bb004e1a8a005f808c025c3feb",
|
||||
"difficulty": "ANOTHER",
|
||||
"songID": 1,
|
||||
"playtype": "SP",
|
||||
"levelNum": 5,
|
||||
"level": "5",
|
||||
"flags": {
|
||||
"IN BASE GAME": true,
|
||||
"OMNIMIX": true,
|
||||
"N-1": true,
|
||||
"2dxtra": false
|
||||
},
|
||||
"data": {
|
||||
"inGameID": 1000,
|
||||
"notecount": 433
|
||||
},
|
||||
"isPrimary": false,
|
||||
"versions": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the chartID is different, and `isPrimary`
|
||||
is set to false.
|
||||
|
||||
Even though this chart is "5.1.1 (SP ANOTHER)", it isn't
|
||||
the primary chart for this songID + playtype + difficulty.
|
||||
|
||||
For the UI, Tachi will hide non-primary charts by default.
|
||||
Realistically, they only exist to support legacy scores
|
||||
without having to throw them away when a rechart occurs.
|
||||
Reference in New Issue
Block a user