sunday 5th apr (rip mark hollis) (#52)

* lots of work

* feat: move rg-stats in, ensure consistency in db queries

* the clocks dont work

* almost

* ok jesus been and gone now back to usual
This commit is contained in:
zk
2026-04-06 01:25:30 +01:00
committed by GitHub
parent 71e8aa2152
commit edda03e916
217 changed files with 11757 additions and 1496 deletions
+11
View File
@@ -0,0 +1,11 @@
---
description: Pointer to coverage aggregation tooling (see skill)
globs: "{Justfile-test,typescript/**/vitest.config.ts,typescript/coverage-tools/**/*.ts}"
alwaysApply: false
---
# Coverage tooling
Full commands, manifest registration, and API: read [.cursor/skills/coverage-tools/SKILL.md](.cursor/skills/coverage-tools/SKILL.md).
Quick: after `just test-typescript`, run `just coverage-report` (add `--by-dir`, `--json`, or `--min-lines N --strict` as needed).
+55
View File
@@ -0,0 +1,55 @@
---
name: coverage-tools
description: >-
Aggregates Vitest v8/Istanbul coverage across Tachi workspaces via
tachi-coverage-tools (manifest, CLI, optional programmatic API). Use when
measuring test coverage, reviewing coverage after changes, adding CI gates,
registering a new Vitest package, or when the user mentions coverage reports,
coverage-final.json, or just coverage-report.
---
# Coverage tools (tachi-coverage-tools)
## When to use
- After running tests with coverage, to see totals and cold areas.
- When adding a new workspace that runs Vitest with `--coverage`.
- To gate CI on minimum line coverage (`--min-lines` + `--strict`).
## Prerequisite
Coverage JSON is produced by Vitest v8 with `--coverage` (e.g. `just test-typescript`). Output is under each package’s `coverage/` directory (gitignored). If reports are missing, run tests with coverage first.
## Commands (repo root)
| Command | Purpose |
|--------|---------|
| `just coverage-report` | Table: lines, statements, functions, branches per registered package |
| `just coverage-report --by-dir` | Same plus top-level `src/*` buckets (server/bot) |
| `just coverage-report --json` | Machine-readable `CoverageReport` on stdout |
| `just coverage-report --packages server` | Filter by manifest `id` (comma-separated) |
| `just coverage-report --min-lines 80 --strict` | Exit non-zero if files missing or line % below floor |
Implementation: workspace package `typescript/coverage-tools` (`bun run --filter tachi-coverage-tools report`).
## Registering a new Vitest package
Edit `typescript/coverage-tools/src/manifest.ts`: add a `CoverageSource` with:
- `id` — short name for `--packages`
- `packageRoot` — e.g. `typescript/my-pkg`
- `coverageFinal` — path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides)
## Programmatic use
```typescript
import { buildReport, COVERAGE_SOURCES } from "tachi-coverage-tools";
```
`buildReport({ repoRoot, packageIds, byDir })` returns `CoverageReport` (see `typescript/coverage-tools/src/report-coverage.ts`).
## Related files
- `typescript/coverage-tools/src/manifest.ts` — source registry
- `typescript/coverage-tools/src/report-coverage.ts` — CLI flags and `buildReport`
- `Justfile-test` — `coverage-report` recipe
+2 -2
View File
@@ -57,8 +57,8 @@ jobs:
env:
NODE_ENV: "test"
PORT: 8080
MONGO_URL: "127.0.0.1"
REDIS_URL: "127.0.0.1"
MONGO_URL: "localhost"
REDIS_URL: "localhost"
docker-push:
runs-on: ubuntu-latest
+1 -1
View File
@@ -20,7 +20,7 @@ for kind in "kamai" "boku"; do
mongodump --archive --port=$remote_port --db=$kind | mongorestore --archive --nsFrom="$kind.*" --nsTo="anon-$kind.*"
ts-node src/scripts/anonymise-db 127.0.0.1:27017/anon-$kind
ts-node src/scripts/anonymise-db localhost:27017/anon-$kind
TCHIS_CONF_LOCATION=$kind.dataset.conf.json5 bun run set-indexes
+1 -1
View File
@@ -46,7 +46,7 @@ docker run --rm \
-v "${DUMP_DIR}:/dump:ro" \
"mongo:$MONGO_VERSION" \
mongorestore \
--host "127.0.0.1:${MONGO_PORT}" \
--host "localhost:${MONGO_PORT}" \
"${RESTORE_ARGS[@]}"
echo "Done."
+28
View File
@@ -7,6 +7,10 @@
* Usage:
* bun .scripts/ts_machete.js # report unused deps
* bun .scripts/ts_machete.js --fix # also remove them from package.json
*
* Root package.json may define `tsMachete.excludeGlobal` (dep names) and
* `tsMachete.exclude` (map of workspace package `name` → dep names) for
* dependencies that are used via scripts, tooling, SCSS, ESLint config, etc.
*/
import path from "node:path";
@@ -29,6 +33,25 @@ process.chdir(cwd);
const rootPkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
const tsMacheteConfig = rootPkg.tsMachete ?? {};
const excludeGlobal = new Set(tsMacheteConfig.excludeGlobal ?? []);
const excludeByPackageName = tsMacheteConfig.exclude ?? {};
/**
* @param {string | undefined} packageName workspace package.json `name`
* @param {string} depName
*/
function isTsMacheteExcluded(packageName, depName) {
if (excludeGlobal.has(depName)) {
return true;
}
if (!packageName) {
return false;
}
const per = excludeByPackageName[packageName];
return Array.isArray(per) && per.includes(depName);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -139,6 +162,8 @@ function getSourceFiles(pkgDir) {
// 3. CommonJS require: require("pkg")
const IMPORT_PATTERNS = [
/\bfrom\s+["']([^"'\s]+)["']/gm,
// Side-effect imports: import "pkg" / import 'pkg' (no `from` clause)
/\bimport\s+["']([^"'\s]+)["']/gm,
/\bimport\(["']([^"'\s]+)["']\)/gm,
/\brequire\(["']([^"'\s]+)["']\)/gm,
];
@@ -228,6 +253,9 @@ for (const workspacePattern of rootPkg.workspaces) {
}
for (const depName of Object.keys(pkg[section])) {
if (isTsMacheteExcluded(pkg.name, depName)) {
continue;
}
if (!isDepUsed(depName, usedPackages)) {
(unusedBySection[section] ??= []).push(depName);
}
+161
View File
@@ -0,0 +1,161 @@
# Security audit — 2026-04-05
Internal review of the Tachi monorepo. Focuses on production-facing attack
surface: user-facing API endpoints, auth flows, trust boundaries, and
dependency advisories.
Regression tests for fixed vulns live in the corresponding action test files.
Informational POCs remain in `typescript/server/src/security-audit/`.
---
## VULN-1 Password reset code never invalidated after use
**Severity:** High
**Endpoint:** `POST /api/v1/auth/reset-password`
**File:** `typescript/server/src/anon-actions/reset-password.ts`
**Status:** FIXED
### Description
`ANON_ACTION_ResetPassword` read the token from `priv_password_reset_token`
with `executeTakeFirstOrThrow` but **never deleted it**. The only constraint
was the 24-hour expiry check (`created_on` vs `GetTimeXHoursAgo(24)`).
### Fix
Replaced the `SELECT` with `DELETE ... RETURNING` so the token is atomically
consumed on first use. A second attempt with the same code returns 404.
### Verification
`anon-actions/reset-password.test.ts` — "deletes the token from
priv_password_reset_token after use" and "rejects a second use of the
same reset code".
---
## VULN-2 OAuth2 client_secret compared with `!==` (timing side-channel)
**Severity:** Medium
**Endpoint:** `POST /api/v1/oauth/token`
**File:** `typescript/server/src/anon-actions/oauth-token-exchange.ts`
**Status:** FIXED
### Description
The secret comparison used `!==` which short-circuits on the first differing
byte, enabling statistical timing analysis to recover the 42-char secret.
### Fix
Replaced with `crypto.timingSafeEqual` (with a length guard for
different-length inputs).
### Verification
`server/router/api/v1/oauth/router.test.ts` — "returns 403 for invalid
client secret" covers correct rejection behavior.
---
## VULN-3 Webhook SSRF — user-controlled `webhookUri` with no host restriction
**Severity:** Medium
**Endpoints:** `POST /api/v1/clients/create`, `PATCH /api/v1/clients/:clientID`
**Files:** `typescript/server/src/actions/create-api-client.ts`,
`typescript/server/src/actions/update-api-client.ts`
**Status:** FIXED
### Description
Any authenticated user could create or update an API client with an arbitrary
`webhookUri` targeting internal infrastructure (AWS metadata, localhost, RFC-1918).
### Fix
Added `validateWebhookUri()` in `typescript/server/src/utils/validate-webhook-uri.ts`.
Enforces HTTPS-only and rejects loopback, localhost, RFC-1918, link-local, and
`0.0.0.0` addresses. Applied in both `create-api-client.ts` and
`update-api-client.ts`.
### Verification
`actions/create-api-client.test.ts` — rejects http, localhost, private IP,
and link-local webhook URIs. `actions/update-api-client.test.ts` — same
checks on the update path, plus confirms null is still accepted.
---
## INFO-1 User enumeration via login & registration
**Severity:** Low (mitigated by captcha + rate limiting)
**Endpoints:** `POST /api/v1/auth/login`, `POST /api/v1/auth/register`
### Description
- **Login:** Returns 404 "This user does not exist" for unknown usernames, vs
403 "Invalid password" for valid usernames. Allows username enumeration.
- **Register:** Returns 409 "This email is already in use". Allows email
enumeration.
### Mitigations already in place
- `AggressiveRateLimitMiddleware` (15 req / 10 min) on both endpoints.
- CAPTCHA validation in production/staging.
- The `forgot-password` and `resend-verify-email` endpoints correctly
return a static response before doing any work, preventing timing leaks.
### Remediation (optional)
Return a generic "Invalid username or password" for both login failure
cases. For registration, silently accept duplicate emails and send a
"you already have an account" notification to the existing email instead
of revealing existence to the caller.
---
## Positive findings
| Area | Notes |
|------|-------|
| **SQL injection** | Search strings use bound Kysely `sql` template parameters. Hostile payloads tested in `songs.test.ts`. |
| **JSONB `sql.lit` keys** | Algorithm/metric keys are whitelisted via `CheckStrProfileAlg` / game config before reaching `sql.lit`. |
| **Password handling** | bcrypt (12 rounds), `crypto.randomBytes` for tokens. |
| **Session config** | `sameSite: "strict"` + `secure: true` in production. Prevents CSRF on cookie-bearing endpoints. |
| **Forgot-password timing** | Response sent *before* DB lookup — cannot enumerate emails via timing. |
| **OAuth2 code** | Single-use (deleted in a transaction during token exchange). |
| **API token lookup** | Done via DB `WHERE` clause (B-tree index), not application-level byte comparison — no timing leak. |
| **IR routes** | Require API token + specific permissions before handlers execute. |
| **Query parser** | `app.set("query parser", "simple")` — disables `qs` nested object parsing, eliminating prototype-pollution vectors through query strings. |
| **Body size limit** | `express.json({ limit: "4mb" })` caps request bodies. |
---
## Dependency advisories
Snapshot from `bun audit` (2026-04-05). Re-run after bumps.
| Package | Severity | Production-relevant? |
|---------|----------|---------------------|
| **multer** ≥1.4.4-lts.1 <2.0.2 | High (DoS) | Yes — file upload routes |
| **kysely** ≥0.26.0 ≤0.28.11 | High (SQLi) | MySQL-focused; lower risk on Postgres but worth patching |
| **fast-xml-parser** ≥4.0.0-beta.3 <4.5.5 | High (entity expansion) | Yes — SSS score import, sieglinde |
| **node-fetch** <2.6.7 | High (header leak) | Yes — server-side fetches to KAI/etc |
| **path-to-regexp** <0.1.12 | High (ReDoS) | Yes — via Express router |
| **lodash** ≤4.17.23 | High (template injection) | Low risk — `_.template` not used with user input |
| **vite** ≤4.5.5 | Moderate (SSRF/fs bypass) | No — dev server only |
| **qs** ≥6.7.0 ≤6.14.1 | Low–Moderate (DoS) | Mitigated by `query parser: "simple"` |
---
## Test index
| Location | Vuln | Notes |
|----------|------|-------|
| `anon-actions/reset-password.test.ts` | VULN-1 | Token invalidation + reuse rejection |
| `server/router/api/v1/oauth/router.test.ts` | VULN-2 | Secret rejection (behavioral) |
| `actions/create-api-client.test.ts` | VULN-3 | webhookUri validation on create |
| `actions/update-api-client.test.ts` | VULN-3 | webhookUri validation on update |
| `security-audit/poc-seeds-shell-injection.test.ts` | Informational | Local-dev only |
| `security-audit/poc-folder-where-sql-raw.test.ts` | Informational | Trusted DB data only |
+2
View File
@@ -1,8 +1,10 @@
import "Justfile-gen"
import "Justfile-apps"
import "Justfile-db"
import "Justfile-backup"
import "Justfile-misc"
import "Justfile-migrate"
import "Justfile-observability"
import "Justfile-test"
import "Justfile-bench"
import "Justfile-dataset"
+1 -1
View File
@@ -20,4 +20,4 @@ server:
bun --watch src/main.ts
client:
cd typescript/client && vite
cd typescript/client && bun vite
+40
View File
@@ -0,0 +1,40 @@
# Postgres backup/restore via Docker (host only). Requires `docker` on PATH — not available
# inside the devcontainer, so these recipes fail fast there.
POSTGRES_CONTAINER := "tachi-postgres"
PG_USER := "tachi"
[private]
assert-docker-host:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
echo "error: docker not found in PATH; run this on the host (not inside the devcontainer)." >&2
exit 1
fi
# Dump POSTGRES_DB to ~/Documents/tachi-postgres-backup-<timestamp>.sql.gz
# Usage: just postgres-backup
# just postgres-backup tachi
postgres-backup POSTGRES_DB='tachi_dev': assert-docker-host
#!/usr/bin/env bash
set -euo pipefail
out="${HOME}/Documents/tachi-postgres-backup-$(date +%Y%m%d-%H%M%S).sql.gz"
mkdir -p "${HOME}/Documents"
echo "Writing $out"
docker exec -t "{{POSTGRES_CONTAINER}}" pg_dump -U "{{PG_USER}}" -d "{{POSTGRES_DB}}" --no-owner --no-acl | gzip -c > "$out"
ls -lh "$out"
gzip -t "$out"
# Restore a gzipped pg_dump into POSTGRES_DB (overwrites existing objects in that DB).
# Usage: just postgres-restore ~/Documents/tachi-postgres-backup-....sql.gz
# just postgres-restore ~/backup.sql.gz tachi
postgres-restore backup_file POSTGRES_DB='tachi_dev': assert-docker-host
#!/usr/bin/env bash
set -euo pipefail
if ! test -f "{{backup_file}}"; then
echo "error: backup file not found: {{backup_file}}" >&2
exit 1
fi
echo "Restoring {{backup_file}} into database {{POSTGRES_DB}}..."
gunzip -c "{{backup_file}}" | docker exec -i "{{POSTGRES_CONTAINER}}" psql -U "{{PG_USER}}" -d "{{POSTGRES_DB}}"
+11
View File
@@ -0,0 +1,11 @@
# Local observability stack (see docker-compose-dev.yml: tachi-grafana, tachi-alloy).
grafana:
python3 -c "import webbrowser; webbrowser.open('http://localhost:3005')"
alloy:
python3 -c "import webbrowser; webbrowser.open('http://localhost:12345')"
# Reload all Grafana provisioning (datasources + dashboards) and verify health.
grafana-reset:
./dev/observability/setup-datasources.sh
+5 -1
View File
@@ -70,10 +70,14 @@ test-typescript FILTER="*":
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
# Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools).
coverage-report *ARGS:
bun run --filter tachi-coverage-tools report -- {{ARGS}}
# Run all parity suites, or a single one if a name is given.
test-parity suite="":
TACHI_SERVER=https://127.0.0.1:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }}
TACHI_SERVER=https://localhost:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }}
# ==> Orchestration of parallel db tests
+79 -485
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
-- Derivation checksum on chart: a SHA-256 hex digest of the chart fields
-- that feed into scoreDeriver/scoreCalcs. When this changes, all scores
-- on the chart need re-derivation.
ALTER TABLE chart ADD COLUMN derivation_checksum TEXT;
-- Dirty queue: score mutations enqueue (user_id, chart_id) pairs that
-- need PB recalculation. Deduplication via the PK.
CREATE TABLE pb_dirty (
user_id BIGINT NOT NULL,
chart_id TEXT NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, chart_id)
);
-- Dirty queue: chart mutations (when derivation_checksum changes) enqueue
-- chart_ids whose scores need re-derivation. One row per chart.
CREATE TABLE score_rederive (
chart_id TEXT NOT NULL PRIMARY KEY,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the
-- (user_id, chart_id) pair as needing PB recalculation.
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (OLD.user_id, OLD.chart_id)
ON CONFLICT DO NOTHING;
ELSE
INSERT INTO pb_dirty (user_id, chart_id)
VALUES (NEW.user_id, NEW.chart_id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER score_pb_dirty
AFTER INSERT OR UPDATE OR DELETE ON score
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
-- Trigger function: on chart UPDATE, if derivation_checksum changed,
-- enqueue the chart for score re-derivation.
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
BEGIN
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
INSERT INTO score_rederive (chart_id)
VALUES (NEW.id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER chart_score_rederive
AFTER UPDATE ON chart
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
-19
View File
@@ -73,24 +73,6 @@ function seedMinioCdn {
)
}
function selfSignHTTPS {
if [ -e typescript/server/cert/key.pem ] && [ -e typescript/server/cert/cert.pem ] && openssl x509 -checkend 0 -noout -in typescript/server/cert/cert.pem; then
echo "HTTPS Certificates for local-dev server already exists and has not expired."
return 0
fi
echo "Self-Signing HTTPS Certificates for local-dev server..."
# This is for dev servers only! You should use this to
# create a self-signed HTTPS certificate for local dev.
# That is it. This is not secure.
mkdir -p typescript/server/cert
openssl req -x509 -newkey rsa:4096 -keyout typescript/server/cert/key.pem -out typescript/server/cert/cert.pem -sha256 -days 365 -nodes -subj "/C=AU/ST=TachiExample/L=TachiExample/O=TachiExample/CN=127.0.0.1" &> /dev/null
echo "Created HTTPS Certificates!"
}
function bunInstall {
echo "Installing dependencies..."
@@ -125,7 +107,6 @@ function syncDatabaseWithSeeds {
setupShell
mvExampleFiles
seedMinioCdn
selfSignHTTPS
bunInstall
if [ -e _SETUP_OK ] && [ $FORCE -eq 0 ]; then
+1 -1
View File
@@ -60,7 +60,7 @@ function fish_greeting
echo $(rgb "This machine comes pre-installed with Tachi and helpful tools." ffffff 000000)
echo ""
echo "Type $(cmd "just start") to start up a frontend and backend."
echo " $(rgb "The server will start on http://127.0.0.1:3000." ffff00 000000)"
echo " $(rgb "The server will start on http://localhost:3000." ffff00 000000)"
echo " $(rgb "Use Ctrl+C to stop the server." ffff00 000000)"
echo ""
echo "Type $(cmd "seeds") to run seeds scripts."
+20
View File
@@ -0,0 +1,20 @@
// Scrape Tachi API Prometheus metrics and remote-write to local Prometheus.
prometheus.remote_write "default" {
endpoint {
url = "http://tachi-prometheus:9090/api/v1/write"
}
}
prometheus.scrape "tachi_api" {
targets = [
{
"__address__" = "tachi-dev:9779",
"job" = "tachi-api",
},
]
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "15s"
metrics_path = "/metrics"
}
@@ -0,0 +1,897 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "node.js prometheus client basic metrics",
"editable": true,
"gnetId": 11159,
"graphTooltip": 0,
"id": null,
"iteration": 1573392431370,
"links": [],
"panels": [
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 7,
"w": 10,
"x": 0,
"y": 0
},
"id": 6,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "irate(process_cpu_user_seconds_total{instance=~\"$instance\"}[2m]) * 100",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "User CPU - {{instance}}",
"refId": "A"
},
{
"expr": "irate(process_cpu_system_seconds_total{instance=~\"$instance\"}[2m]) * 100",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Sys CPU - {{instance}}",
"refId": "B"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Process CPU Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "percent",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 7,
"w": 9,
"x": 10,
"y": 0
},
"id": 8,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "nodejs_eventloop_lag_seconds{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{instance}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Event Loop Lag",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "s",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorValue": false,
"colors": [
"#299c46",
"rgba(237, 129, 40, 0.89)",
"#d44a3a"
],
"datasource": "Prometheus",
"format": "none",
"gauge": {
"maxValue": 100,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 3,
"w": 5,
"x": 19,
"y": 0
},
"id": 2,
"interval": "",
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": false,
"lineColor": "rgb(31, 120, 193)",
"show": false
},
"tableColumn": "__name__",
"targets": [
{
"expr": "sum(nodejs_version_info{instance=~\"$instance\"}) by (version)",
"format": "time_series",
"instant": false,
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{version}}",
"refId": "A"
}
],
"thresholds": "",
"timeFrom": null,
"timeShift": null,
"title": "Node.js Version",
"type": "singlestat",
"valueFontSize": "80%",
"valueMaps": [
{
"op": "=",
"text": "N/A",
"value": "null"
}
],
"valueName": "name"
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorValue": false,
"colors": [
"#299c46",
"rgba(237, 129, 40, 0.89)",
"#d44a3a"
],
"datasource": "Prometheus",
"format": "none",
"gauge": {
"maxValue": 100,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 3
},
"id": 4,
"interval": null,
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": false,
"lineColor": "#F2495C",
"show": true
},
"tableColumn": "",
"targets": [
{
"expr": "sum(changes(process_start_time_seconds{instance=~\"$instance\"}[1m]))",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{instance}}",
"refId": "A"
}
],
"thresholds": "",
"timeFrom": null,
"timeShift": null,
"title": "Process Restart Times",
"type": "singlestat",
"valueFontSize": "80%",
"valueMaps": [
{
"op": "=",
"text": "N/A",
"value": "null"
}
],
"valueName": "current"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 7,
"w": 16,
"x": 0,
"y": 7
},
"id": 7,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "process_resident_memory_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Process Memory - {{instance}}",
"refId": "A"
},
{
"expr": "nodejs_heap_size_total_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Heap Total - {{instance}}",
"refId": "B"
},
{
"expr": "nodejs_heap_size_used_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Heap Used - {{instance}}",
"refId": "C"
},
{
"expr": "nodejs_external_memory_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "External Memory - {{instance}}",
"refId": "D"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Process Memory Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 7,
"w": 8,
"x": 16,
"y": 7
},
"id": 9,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "nodejs_active_handles_total{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Active Handler - {{instance}}",
"refId": "A"
},
{
"expr": "nodejs_active_requests_total{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Active Request - {{instance}}",
"refId": "B"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Active Handlers/Requests Total",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 14
},
"id": 10,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"rightSide": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "nodejs_heap_space_size_total_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Heap Total - {{instance}} - {{space}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Heap Total Detail",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 14
},
"id": 11,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"rightSide": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "nodejs_heap_space_size_used_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Heap Used - {{instance}} - {{space}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Heap Used Detail",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 14
},
"id": 12,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"max": true,
"min": true,
"rightSide": false,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"paceLength": 10,
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "nodejs_heap_space_size_available_bytes{instance=~\"$instance\"}",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "Heap Used - {{instance}} - {{space}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Heap Available Detail",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"schemaVersion": 18,
"style": "dark",
"tags": [
"nodejs"
],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": "Prometheus",
"definition": "label_values(nodejs_version_info, instance)",
"hide": 0,
"includeAll": true,
"label": "instance",
"multi": true,
"name": "instance",
"options": [],
"query": "label_values(nodejs_version_info, instance)",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "",
"title": "NodeJS Application Dashboard",
"uid": "PTSqcpJWk",
"version": 4
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: default
orgId: 1
folder: ""
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,57 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://tachi-prometheus:9090
isDefault: true
editable: false
- name: Postgres (tachi_dev)
type: postgres
access: proxy
url: tachi-postgres:5432
user: tachi
secureJsonData:
password: tachi
jsonData:
database: tachi_dev
sslmode: disable
maxOpenConns: 5
maxIdleConns: 2
connMaxLifetime: 14400
isDefault: false
editable: true
- name: Postgres (kamai)
type: postgres
access: proxy
url: tachi-postgres:5432
user: tachi
secureJsonData:
password: tachi
jsonData:
database: kamai
sslmode: disable
maxOpenConns: 5
maxIdleConns: 2
connMaxLifetime: 14400
isDefault: false
editable: true
- name: Postgres (boku)
type: postgres
access: proxy
url: tachi-postgres:5432
user: tachi
secureJsonData:
password: tachi
jsonData:
database: boku
sslmode: disable
maxOpenConns: 5
maxIdleConns: 2
connMaxLifetime: 14400
isDefault: false
editable: true
+5
View File
@@ -0,0 +1,5 @@
# Metrics are ingested via remote write from Grafana Alloy (see alloy-config.alloy).
global:
scrape_interval: 15s
scrape_configs: []
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
GRAFANA_URL="${GRAFANA_URL:-http://tachi-grafana:3000}"
GRAFANA_USER="${GRAFANA_USER:-admin}"
GRAFANA_PASS="${GRAFANA_PASS:-admin}"
PROM_URL="${PROM_URL:-http://tachi-prometheus:9090}"
ALLOY_URL="${ALLOY_URL:-http://tachi-alloy:12345}"
MAX_WAIT="${MAX_WAIT:-60}"
auth="${GRAFANA_USER}:${GRAFANA_PASS}"
wait_for_grafana() {
local elapsed=0
printf "Waiting for Grafana at %s " "$GRAFANA_URL"
while ! curl -sf -o /dev/null "${GRAFANA_URL}/api/health" 2>/dev/null; do
if (( elapsed >= MAX_WAIT )); then
printf "\nGrafana did not become ready within %ds\n" "$MAX_WAIT" >&2
exit 1
fi
sleep 2
elapsed=$((elapsed + 2))
printf "."
done
printf " ready (%ds)\n" "$elapsed"
}
upsert_datasource() {
local name="$1" type="$2" url="$3" is_default="$4"
local existing
existing=$(curl -sf -u "$auth" "${GRAFANA_URL}/api/datasources/name/${name}" 2>/dev/null || true)
local payload
payload=$(cat <<EOF
{
"name": "${name}",
"type": "${type}",
"access": "proxy",
"url": "${url}",
"isDefault": ${is_default},
"editable": true
}
EOF
)
if [ -n "$existing" ] && echo "$existing" | grep -q '"id"'; then
if echo "$existing" | grep -q '"readOnly":true'; then
printf " %-12s ok (provisioned, read-only)\n" "$name"
return 0
fi
local uid
uid=$(echo "$existing" | grep -o '"uid":"[^"]*"' | head -1 | cut -d'"' -f4)
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -u "$auth" \
-X PUT "${GRAFANA_URL}/api/datasources/uid/${uid}" \
-H "Content-Type: application/json" \
-d "$payload")
if [ "$status" = "200" ]; then
printf " %-12s updated (uid=%s)\n" "$name" "$uid"
else
printf " %-12s update failed (HTTP %s)\n" "$name" "$status" >&2
return 1
fi
else
local resp
resp=$(curl -s -u "$auth" \
-X POST "${GRAFANA_URL}/api/datasources" \
-H "Content-Type: application/json" \
-d "$payload" 2>&1) || true
if echo "$resp" | grep -q '"datasource"'; then
local uid
uid=$(echo "$resp" | grep -o '"uid":"[^"]*"' | head -1 | cut -d'"' -f4)
printf " %-12s created (uid=%s)\n" "$name" "$uid"
elif echo "$resp" | grep -q "data source with the same name already exists"; then
printf " %-12s already exists (provisioned)\n" "$name"
else
printf " %-12s create failed: %s\n" "$name" "$resp" >&2
return 1
fi
fi
}
health_check() {
local name="$1"
local resp
resp=$(curl -sf -u "$auth" "${GRAFANA_URL}/api/datasources/name/${name}" 2>/dev/null || true)
if [ -z "$resp" ]; then
printf " %-12s not found\n" "$name" >&2
return 1
fi
local uid
uid=$(echo "$resp" | grep -o '"uid":"[^"]*"' | head -1 | cut -d'"' -f4)
local check
check=$(curl -sf -u "$auth" \
-X POST "${GRAFANA_URL}/api/datasources/uid/${uid}/health" \
-H "Content-Type: application/json" 2>&1) || true
if echo "$check" | grep -q '"status":"OK"'; then
printf " %-12s healthy\n" "$name"
elif echo "$check" | grep -q '"status":"ERROR"'; then
local msg
msg=$(echo "$check" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4)
printf " %-12s unhealthy: %s\n" "$name" "$msg" >&2
return 1
else
printf " %-12s health unknown (endpoint may not be supported)\n" "$name"
fi
}
reload_provisioning() {
local kind="$1"
local status
status=$(curl -sf -o /dev/null -w "%{http_code}" -u "$auth" \
-X POST "${GRAFANA_URL}/api/admin/provisioning/${kind}/reload" 2>/dev/null) || true
if [ "$status" = "200" ]; then
printf " %-12s reloaded\n" "$kind"
else
printf " %-12s reload failed (HTTP %s)\n" "$kind" "$status" >&2
fi
}
wait_for_grafana
echo "Registering datasources:"
upsert_datasource "Prometheus" "prometheus" "$PROM_URL" "true"
echo "Reloading provisioning:"
reload_provisioning "datasources"
reload_provisioning "dashboards"
echo "Health checks:"
health_check "Prometheus"
health_check "Postgres"
echo "Reloading Alloy config:"
alloy_status=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${ALLOY_URL}/-/reload" 2>/dev/null) || true
if [ "$alloy_status" = "200" ]; then
printf " %-12s reloaded\n" "Alloy"
else
printf " %-12s reload failed (HTTP %s)\n" "Alloy" "$alloy_status" >&2
fi
echo "Done."
+12
View File
@@ -4,3 +4,15 @@ GRANT ALL PRIVILEGES ON DATABASE tachi_dev TO tachi;
-- Match genesis: pg_stat_statements (requires shared_preload_libraries in docker-compose).
\connect tachi_dev
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Read-only user for Grafana dashboards.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'grafana_ro') THEN
CREATE ROLE grafana_ro WITH LOGIN PASSWORD 'grafana_ro';
END IF;
END$$;
GRANT CONNECT ON DATABASE tachi_dev TO grafana_ro;
GRANT USAGE ON SCHEMA public TO grafana_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro;
+60 -1
View File
@@ -76,6 +76,7 @@ services:
- "8080:8080" # server
- "3000:3000" # client
- "3001:3001" # docs
- "9779:9779" # metrics
volumes:
- ./:/tachi
# Mount your home files under your-pc, so you can easily access them
@@ -84,9 +85,67 @@ services:
- /tachi/node_modules/
- /tachi/.bun
# Local observability (Grafana + Prometheus + Alloy). Grafana UI: http://localhost:3005
# Alloy scrapes the API metrics endpoint at tachi-dev:9779 and remote-writes into Prometheus.
tachi-grafana:
container_name: tachi-grafana
image: grafana/grafana:12.4.2
restart: unless-stopped
depends_on:
- tachi-prometheus
ports:
- "3005:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_HTTP_ADDR: 0.0.0.0
GF_SERVER_HTTP_PORT: "3000"
GF_SERVER_ROOT_URL: http://localhost:3005
volumes:
- tachi-grafana:/var/lib/grafana
- ./dev/observability/grafana/provisioning:/etc/grafana/provisioning
- ./dev/observability/grafana/dashboards:/var/lib/grafana/dashboards
tachi-prometheus:
container_name: tachi-prometheus
image: prom/prometheus:v3.11.0
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.enable-lifecycle
- --web.enable-remote-write-receiver
ports:
- "9090:9090"
volumes:
- tachi-prometheus:/prometheus
- ./dev/observability/prometheus.yml:/etc/prometheus/prometheus.yml
tachi-alloy:
container_name: tachi-alloy
image: grafana/alloy:v1.15.0
restart: unless-stopped
depends_on:
- tachi-prometheus
command:
- run
- --server.http.listen-addr=0.0.0.0:12345
- --storage.path=/var/lib/alloy/data
- /etc/alloy/config.alloy
ports:
- "12345:12345"
# Writable WAL path (named volumes are often root-owned; Alloy runs non-root and can crash-loop).
tmpfs:
- /var/lib/alloy/data
volumes:
- ./dev/observability/alloy-config.alloy:/etc/alloy/config.alloy
volumes:
tachi-volume:
tachi-redis:
tachi-logs:
tachi-postgres:
tachi-minio:
tachi-minio:
tachi-grafana:
tachi-prometheus:
+1 -1
View File
@@ -2,6 +2,6 @@
interactive:
-@cd ../ && just
# Run just the docs on http://127.0.0.1:3001.
# Run just the docs on http://localhost:3001.
start:
mkdocs serve -a 0.0.0.0:3001
+3 -16
View File
@@ -36,7 +36,6 @@ the main benefits for us are as follows:
REDIRECT_URI: "https://example.com",
},
ARC_AUTH_TOKEN: "unused",
ENABLE_SERVER_HTTPS: false,
OUR_URL: "https://example.com",
INVITE_CODE_CONFIG: {
BATCH_SIZE: 2,
@@ -44,7 +43,7 @@ the main benefits for us are as follows:
BETA_USER_BONUS: 5,
},
CDN_CONFIG: {
WEB_LOCATION: "http://127.0.0.1:9000/tachi-public",
WEB_LOCATION: "http://localhost:9000/tachi-public",
SAVE_LOCATION: {
TYPE: "S3_BUCKET",
ENDPOINT: "http://tachi-s3:9000",
@@ -161,18 +160,6 @@ If present, these define our OAuth2 Client data for interacting with these servi
Maybe a better solution would be to have the API_URL inside the OAUTH2_INFO. Ah well.
### ENABLE_SERVER_HTTPS
- Type: Boolean (optional)
Whether to use HTTPS. If this is set to true, the files `cert/key.pem` and `cert/cert.pem` will be
used as the privateKey and the certificate, respectively.
!!! note
In production, we use an Nginx reverse proxy which handles this.
A warning will be emitted if you are using HTTPS mode, as it's not generally meant to be used
at this level.
### CLIENT_DEV_SERVER
- Type: String
@@ -374,7 +361,7 @@ If no LOGGER_CONFIG is provided, this is not set.
Configures the CDN for the Tachi Server. Files are always stored in an S3-compatible bucket (AWS S3, Backblaze, MinIO, etc.).
For local development with `docker-compose-dev.yml`, run the `tachi-s3` MinIO service and point `SAVE_LOCATION.ENDPOINT` at it (from the `tachi-dev` container, `http://tachi-s3:9000`). Use a `WEB_LOCATION` URL that browsers can load (for example `http://127.0.0.1:9000/<bucket>` when MinIO’s API port is published to the host).
For local development with `docker-compose-dev.yml`, run the `tachi-s3` MinIO service and point `SAVE_LOCATION.ENDPOINT` at it (from the `tachi-dev` container, `http://tachi-s3:9000`). Use a `WEB_LOCATION` URL that browsers can load (for example `http://localhost:9000/<bucket>` when MinIO’s API port is published to the host).
```ts
interface CDN_CONFIG: {
@@ -414,7 +401,7 @@ If not set, this will log a warning and default to 8080.
### MONGO_URL
Where our mongoDB instance is. This would be `127.0.0.1:27017` if hosting on the same box.
Where our mongoDB instance is. This would be `localhost:27017` if hosting on the same box.
If not set, this will terminate the process with a critical error.
### SEQ_URL
+1 -1
View File
@@ -20,7 +20,7 @@ To run the client and server, use `just start`. You can hit `Ctrl+C` to stop the
## Editing the Client
With `just start` running, the client will listen for changes you make, and reload accordingly. You will see your changes reflected on http://127.0.0.1:3000.
With `just start` running, the client will listen for changes you make, and reload accordingly. You will see your changes reflected on http://localhost:3000.
## Editing the Server
@@ -28,7 +28,7 @@ Other than that, our documentation is [markdown](https://www.markdownguide.org/b
## Running the Documentation
Use `just docs start` inside Tachi to start up a local documentation viewer on http://127.0.0.1:8001.
Use `just docs start` inside Tachi to start up a local documentation viewer on http://localhost:8001.
This will automatically refresh when you edit anything related to the documentation, so you can quickly see how your stuff goes.
+3 -11
View File
@@ -158,21 +158,13 @@ You should now be properly authenticated!
With a terminal open inside the `Tachi` container you just cloned, run `just start`.
The frontend will be running on `http://127.0.0.1:3000`.
The backend will be running on `https://127.0.0.1:8080`.
!!! danger
The backend **always runs on HTTPS** in local development. This is because browsers
tend to _really_ hate HTTP mode nowadays, and it causes so many problems.
You **need** to navigate to your running instance of the backend in a browser, and tell
the browser that you trust these certificates. Otherwise, all client requests to
the server will silently be chomped by the browser.
The frontend will be running on `http://localhost:3000`.
The backend listens on `http://localhost:8080`, and the Vite dev server **proxies** `/api` and `/ir` there so the browser talks to the app on port 3000 only (same-origin HTTP, no self-signed certificates).
!!! tip
Type `just` in the terminal to see other available commands.
Navigate to http://127.0.0.1:3000 and check your Tachi instance!
Navigate to http://localhost:3000 and check your Tachi instance!
## 6. OK, Now what.
+143 -174
View File
@@ -7,6 +7,47 @@
"typescript/*",
"typescript-vendor/*"
],
"tsMachete": {
"excludeGlobal": [
"@typescript/native-preview",
"typescript"
],
"exclude": {
"tachi-client": [
"@types/history",
"bootstrap-scss",
"sass"
],
"tachi-bot": [
"ts-node",
"tsconfig-paths",
"@vitest/coverage-v8"
],
"tachi-db": [
"@types/express",
"@types/node",
"@types/node-fetch",
"@vitest/coverage-v8"
],
"eslint-config-tachi": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
"eslint-import-resolver-typescript",
"@types/eslint__js"
],
"tachi-seeds-scripts": [
"tsconfig-paths"
],
"tachi-server": [
"ts-node",
"tsconfig-paths",
"@vitest/coverage-v8"
],
"tachi-db-migration-engine": [
"ts-node"
]
}
},
"author": "zk",
"devDependencies": {
"@biomejs/biome": "^2.0.6",
@@ -18,64 +59,117 @@
"kanel": "^3.16.1",
"kanel-kysely": "^0.8.0",
"ts-node": "10.9.1",
"tsc-alias": "1.7.1",
"typescript": "5.9.3"
},
"peerDependencies": {
"@types/bson": "1.0.11",
"@types/mongodb": "3.6.20",
"@types/node": "20.19.37",
"mongodb": "3.6.3"
},
"catalog": {
"@typescript/native-preview": "7.0.0-dev.20260309.1",
"@types/node": "*",
"@types/pg": "^8.11.11",
"ts-node": "^8.5.2",
"typescript": "^4.3.4",
"kysely": "^0.27.5",
"pg": "^8.13.3",
"@types/history": "4.7.11",
"@types/humanize-duration": "^3.27.1",
"@types/luxon": "^2.3.1",
"@types/react": "17.0.13",
"@types/react-dom": "17.0.8",
"@types/react-router-bootstrap": "0.26.1",
"@types/react-router-dom": "5.1.7",
"@vitejs/plugin-react": "^6.0.1",
"bootstrap-scss": "^5.3.0",
"sass": "^1.77.0",
"vite": "^8.0.0",
"@babel/core": "7.1.0",
"@aws-sdk/client-s3": "3.49.0",
"@discordjs/builders": "0.5.0",
"@discordjs/rest": "0.1.0-canary.0",
"@eslint/js": "^9.20.0",
"@fortawesome/fontawesome-free": "5.13.0",
"@fullcalendar/core": "^6.0.0",
"@fullcalendar/daygrid": "^6.0.0",
"@fullcalendar/react": "^6.0.0",
"@nivo/bar": "0.79.1",
"@nivo/colors": "0.79.1",
"@nivo/core": "0.79.0",
"@nivo/line": "0.79.1",
"@nivo/tooltip": "0.79.0",
"@octokit/app": "15.1.0",
"@octokit/webhooks-types": "7.5.1",
"@types/bcryptjs": "^2.4.2",
"@types/bunyan": "^1.8.8",
"@types/connect-redis": "0.0.18",
"@types/eslint__js": "8.42.3",
"@types/express": "4.17.13",
"@types/express-rate-limit": "5.1.3",
"@types/express-serve-static-core": "4.17.29",
"@types/express-session": "1.17.4",
"@types/history": "4.7.11",
"@types/humanize-duration": "^3.27.1",
"@types/lodash": "4.14.172",
"@types/lodash.get": "^4.4.7",
"@types/luxon": "^2.3.1",
"@types/mongodb": "3.6.20",
"@types/multer": "1.4.7",
"@types/natural-compare": "^1.4.1",
"@types/node": "*",
"@types/node-fetch": "2.5.12",
"@types/nodemailer": "^6.4.4",
"@types/pg": "^8.11.11",
"@types/rate-limit-redis": "1.7.4",
"@types/react": "17.0.13",
"@types/react-dom": "17.0.8",
"@types/react-google-recaptcha": "2.1.2",
"csstype": "3.1.1",
"d3-scale": "3.3.0",
"@types/react-router-bootstrap": "0.26.1",
"@types/react-router-dom": "5.1.7",
"@types/redis": "2.8.32",
"@types/supertest": "2.0.11",
"@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0",
"@typescript-eslint/utils": "^8.24.1",
"@typescript/native-preview": "7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "~3.2.0",
"bcryptjs": "2.4.3",
"better-sqlite3": "9.4.5",
"binary-parser": "^2.3.0",
"bms-table-loader": "2.0.0",
"bootstrap-scss": "^5.3.0",
"bullmq": "1.68.4",
"bunyan": "1.8.15",
"chalk": "4",
"cheerio": "^1.0.0-rc.12",
"commander": "^8.2.0",
"connect-redis": "6.1.1",
"csv-parse": "^5.0.3",
"deepmerge": "^4.2.2",
"discord-api-types": "0.22.0",
"discord.js": "13.6.0",
"dotenv": "16.0.0",
"encoding-japanese": "^1.0.30",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-perfectionist": "^3.9.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.14",
"express": "4.20.0",
"express-async-errors": "3.1.1",
"express-prom-bundle": "^7.0.0",
"express-rate-limit": "5.5.1",
"express-session": "1.17.2",
"fast-json-stable-hash": "^1.0.3",
"fast-xml-parser": "^4.2.5",
"formik": "2.2.9",
"fs-extra": "7.0.1",
"hash.js": "1.1.7",
"globals": "^15.15.0",
"helmet": "5.0.2",
"html-entities": "^2.3.2",
"humanize-duration": "^3.27.1",
"json-2-csv": "3.14.4",
"iconv-lite": "^0.6.3",
"kysely": "^0.27.5",
"lodash": "^4.17.23",
"lodash.get": "^4.4.2",
"luxon": "^2.3.1",
"mongodb": "3.6.3",
"monk": "^7.3.4",
"multer": "2.0.1",
"nanoid": "3.1.23",
"natural-compare": "^1.4.0",
"node-cache": "5.1.2",
"node-fetch": "2.6.7",
"node-html-parser": "^5.3.3",
"node-mocks-http": "1.11.0",
"node-sass": "9.0.0",
"nodemailer": "7.0.11",
"pg": "^8.13.3",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "3.2.5",
"pretty-bytes": "5.6.0",
"prop-types": "15.6.0",
"prom-client": "15.1.3",
"prudence": "0.10.0",
"rate-limit-redis": "2.1.0",
"react": "17.0.2",
"react-async": "10.0.1",
"react-bootstrap": "2.9.0-beta.0",
@@ -87,147 +181,23 @@
"react-router-bootstrap": "^0.25.0",
"react-router-dom": "5.1.2",
"react-select": "^5.6.1",
"rg-stats": "1.2.1",
"sync-fetch": "^0.3.1",
"vite-plugin-html": "^3.2.0",
"@types/express-serve-static-core": "4.17.29",
"@types/mongodb": "3.6.20",
"zod": "^4.3.6",
"@types/express": "4.17.13",
"@types/json5": "2.2.0",
"@types/node-fetch": "2.5.12",
"@discordjs/builders": "0.5.0",
"@discordjs/rest": "0.1.0-canary.0",
"@types/bson": "1.0.11",
"@valuabletouch/winston-seq": "1.3.0",
"discord-api-types": "0.22.0",
"discord.js": "13.6.0",
"express": "4.20.0",
"json5": "2.2.0",
"monk": "^7.3.4",
"node-fetch": "2.6.7",
"seq-logging": "1.1.1",
"tsconfig-paths": "3.12.0",
"winston": "3.5.1",
"@grpc/grpc-js": "^1.10.6",
"@types/google-protobuf": "^3.15.12",
"@types/supertest": "2.0.11",
"glob": "^7.1.6",
"google-protobuf": "^3.21.2",
"grpc-tools": "^1.12.4",
"node-mocks-http": "1.11.0",
"nodemon": "^2.0.20",
"supertest": "6.2.2",
"ts-protoc-gen": "^0.15.0",
"@aws-sdk/client-s3": "3.49.0",
"@aws-sdk/signature-v4-crt": "3.49.0",
"@types/bcryptjs": "^2.4.2",
"@types/bunyan": "^1.8.8",
"@types/connect-redis": "0.0.18",
"@types/express-rate-limit": "5.1.3",
"@types/express-session": "1.17.4",
"@types/glob": "^7.2.0",
"@types/mkdirp": "1.0.2",
"@types/multer": "1.4.7",
"@types/nodemailer": "^6.4.4",
"@types/rate-limit-redis": "1.7.4",
"@types/redis": "2.8.32",
"@types/rimraf": "3.0.2",
"@types/safe-json-stringify": "1.1.2",
"@types/semver": "^7.3.9",
"bcryptjs": "2.4.3",
"bms-table-loader": "2.0.0",
"bullmq": "1.68.4",
"bunyan": "1.8.15",
"commander": "^8.2.0",
"connect-redis": "6.1.1",
"csv-parse": "^5.0.3",
"express-async-errors": "3.1.1",
"express-prom-bundle": "^7.0.0",
"express-rate-limit": "5.5.1",
"express-session": "1.17.2",
"fast-xml-parser": "^4.2.5",
"helmet": "5.0.2",
"html-entities": "^2.3.2",
"mkdirp": "^1.0.4",
"mongodb": "3.6.3",
"multer": "2.0.1",
"node-cache": "5.1.2",
"node-html-parser": "^5.3.3",
"nodemailer": "7.0.11",
"rate-limit-redis": "2.1.0",
"redis": "3.1.2",
"rimraf": "^3.0.0",
"safe-json-stringify": "1.2.0",
"semver": "^7.3.7",
"uuidv7": "^1.1.0",
"winston-loki": "^6.1.0",
"winston-transport": "4.5.0",
"@octokit/app": "15.1.0",
"@octokit/webhooks-types": "7.5.1",
"node-sass": "9.0.0",
"prettier": "3.2.5",
"@types/lodash.get": "^4.4.7",
"better-sqlite3": "9.4.5",
"binary-parser": "^2.3.0",
"chalk": "4",
"cheerio": "^1.0.0-rc.12",
"encoding-japanese": "^1.0.30",
"iconv-lite": "^0.6.3",
"lodash.get": "^4.4.2",
"flow-remove-types": "^2.112.0",
"node-preload": "^0.2.1",
"process-on-spawn": "^1.0.0",
"chokidar": "^3.3.0",
"coveralls": "^3.0.11",
"findit": "^2.0.0",
"foreground-child": "^2.0.0",
"fs-exists-cached": "^1.0.0",
"import-jsx": "^4.0.0",
"ink": "^2.7.1",
"isexe": "^2.0.0",
"istanbul-lib-processinfo": "^2.0.2",
"jackspeak": "^1.4.0",
"libtap": "^1.1.1",
"minipass": "^3.1.1",
"nyc": "^15.1.0",
"opener": "^1.5.1",
"signal-exit": "^3.0.0",
"source-map-support": "^0.5.16",
"tap-mocha-reporter": "^5.0.0",
"tap-parser": "^10.0.1",
"tap-yaml": "^1.0.0",
"tcompare": "^5.0.6",
"treport": "^2.0.2",
"which": "^2.0.2",
"@types/eslint__js": "8.42.3",
"@eslint/js": "^9.20.0",
"@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0",
"@typescript-eslint/utils": "^8.24.1",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-perfectionist": "^3.9.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.15.0",
"rg-stats": "workspace:*",
"sass": "^1.77.0",
"supertest": "6.2.2",
"sync-fetch": "^0.3.1",
"ts-node": "^8.5.2",
"tsconfig-paths": "3.12.0",
"typescript": "^4.3.4",
"typescript-eslint": "^8.24.1",
"vite": "^8.0.0",
"vite-plugin-html": "^3.2.0",
"vitest": "~3.2.0",
"@vitest/coverage-v8": "~3.2.0",
"@vitest/coverage-istanbul": "~3.2.0",
"pino": "^10.3.1",
"@types/jest": "^26.0.23",
"eslint": "^7.29.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-jest": "^24.3.6",
"jest": "^27.0.4",
"standard": "^16.0.3",
"ts-jest": "^27.0.3",
"tsd": "^0.17.0",
"pino-pretty": "^13.1.3"
"zod": "^4.3.6",
"@types/tap": "^15.0.5",
"tap": "^15.1.6",
"@types/istanbul-lib-coverage": "^2.0.6",
"istanbul-lib-coverage": "^3.2.2"
},
"overrides": {
"@types/mongodb": "3.6.20",
@@ -242,8 +212,7 @@
"patchedDependencies": {
"@types/mongodb@3.6.20": "patches/@types__mongodb@3.6.20.patch",
"browserslist@4.21.4": "patches/browserslist@4.21.4.patch",
"monk@7.3.4": "patches/monk@7.3.4.patch",
"winston-loki@6.1.0": "patches/winston-loki@6.1.0.patch"
"monk@7.3.4": "patches/monk@7.3.4.patch"
},
"engines": {
"bun": ">=1.2.0"
-15
View File
@@ -1,15 +0,0 @@
diff --git a/index.js b/index.js
index e1c76051045ea9b69b320283a2f27cd2b9ea5cb4..65d183c93ba15242826ccfaf3528e38ebe701f03 100644
--- a/index.js
+++ b/index.js
@@ -65,6 +65,7 @@ class LokiTransport extends Transport {
}
lokiLabels = Object.assign(lokiLabels, labels)
+ lokiLabels = Object.assign(lokiLabels, rest)
// follow the format provided
const line = this.useCustomFormat
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index e08faec5f46b8d3802f1a8bf7191fe41d695e047..0000000000000000000000000000000000000000
+1 -9
View File
@@ -17,7 +17,6 @@
},
"devDependencies": {
"@types/express": "catalog:",
"@types/json5": "catalog:",
"@types/lodash": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
@@ -31,16 +30,11 @@
"dependencies": {
"@discordjs/builders": "catalog:",
"@discordjs/rest": "catalog:",
"@types/bson": "catalog:",
"@types/humanize-duration": "catalog:",
"@valuabletouch/winston-seq": "catalog:",
"deepmerge": "catalog:",
"discord-api-types": "catalog:",
"discord.js": "catalog:",
"dotenv": "catalog:",
"express": "catalog:",
"humanize-duration": "catalog:",
"json5": "catalog:",
"kysely": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
@@ -48,13 +42,11 @@
"pg": "catalog:",
"bliss": "workspace:*",
"prudence": "catalog:",
"seq-logging": "catalog:",
"tachi-common": "workspace:*",
"tachi-db": "workspace:*",
"ts-node": "catalog:",
"tsconfig-paths": "catalog:",
"typescript": "catalog:",
"winston": "catalog:"
"typescript": "catalog:"
},
"engines": {
"bun": ">=1.2.0"
+9 -8
View File
@@ -2,6 +2,7 @@ import type { MONGO_ImportDocument } from "tachi-common";
import db from "#services/pg/db";
import { createTestAccount } from "#test-utils/db";
import { SELECT_ACTION } from "#test-utils/select-constants";
import { type ActionTaker, ExpectedErr } from "bliss";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -70,8 +71,8 @@ describe("ACTION_Sync", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "SYNC")
.select(SELECT_ACTION)
.where("action.kind", "=", "SYNC")
.executeTakeFirst();
const input = action?.input as Record<string, unknown>;
@@ -105,8 +106,8 @@ describe("ACTION_Sync", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "SYNC")
.select(SELECT_ACTION)
.where("action.kind", "=", "SYNC")
.executeTakeFirst();
// node-pg returns BIGINT columns as strings
@@ -127,8 +128,8 @@ describe("ACTION_Sync", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "SYNC")
.select(SELECT_ACTION)
.where("action.kind", "=", "SYNC")
.executeTakeFirst();
expect(action?.result).toBe("BAD");
@@ -141,8 +142,8 @@ describe("ACTION_Sync", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "SYNC")
.select(SELECT_ACTION)
.where("action.kind", "=", "SYNC")
.executeTakeFirst();
expect(action?.result).toBe("THROW");
@@ -1,6 +1,7 @@
import type { GuildMember } from "discord.js";
import db from "#services/pg/db";
import { SELECT_ACTION } from "#test-utils/select-constants";
import { type AnonActionTaker } from "bliss";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -57,8 +58,8 @@ describe("ACTION_Letmein", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "LETMEIN")
.select(SELECT_ACTION)
.where("action.kind", "=", "LETMEIN")
.executeTakeFirst();
// node-pg returns BIGINT columns as strings
@@ -91,8 +92,8 @@ describe("ACTION_Letmein", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "LETMEIN")
.select(SELECT_ACTION)
.where("action.kind", "=", "LETMEIN")
.executeTakeFirst();
expect(action?.result).toBe("THROW");
@@ -1,5 +1,6 @@
import db from "#services/pg/db";
import { createTestAccount } from "#test-utils/db";
import { SELECT_ACTION, SELECT_PRIV_DISCORD_USER_MAP } from "#test-utils/select-constants";
import { beforeEach, describe, expect, it } from "vitest";
import { ANON_ACTION_Register } from "./register";
@@ -27,8 +28,8 @@ describe("ACTION_Register", () => {
const row = await db
.selectFrom("priv_discord_user_map")
.selectAll()
.where("user_id", "=", userId)
.select(SELECT_PRIV_DISCORD_USER_MAP)
.where("priv_discord_user_map.user_id", "=", userId)
.executeTakeFirst();
expect(row).toMatchObject({
@@ -58,8 +59,8 @@ describe("ACTION_Register", () => {
const row = await db
.selectFrom("priv_discord_user_map")
.selectAll()
.where("user_id", "=", userId)
.select(SELECT_PRIV_DISCORD_USER_MAP)
.where("priv_discord_user_map.user_id", "=", userId)
.executeTakeFirst();
expect(row?.discord_id).toBe(newDiscordId);
@@ -74,8 +75,8 @@ describe("ACTION_Register", () => {
const action = await db
.selectFrom("action")
.selectAll()
.where("kind", "=", "REGISTER")
.select(SELECT_ACTION)
.where("action.kind", "=", "REGISTER")
.executeTakeFirst();
expect(action).toMatchObject({
@@ -0,0 +1,20 @@
/** Kysely column lists for tests (mirrors `tachi-server` db-formats where applicable). */
export const SELECT_ACTION = [
"action.row_id",
"action.user_id",
"action.ip",
"action.app",
"action.kind",
"action.result",
"action.input",
"action.output",
"action.ts_start",
"action.ts_end",
] as const;
export const SELECT_PRIV_DISCORD_USER_MAP = [
"priv_discord_user_map.user_id",
"priv_discord_user_map.discord_id",
"priv_discord_user_map.api_token",
] as const;
+3 -2
View File
@@ -1,6 +1,7 @@
VITE_TCHIC_MODE="omni"
VITE_SERVER_URL="https://127.0.0.1:8080"
VITE_CDN_URL="http://127.0.0.1:9000/tachi-public"
# Empty = same-origin; Vite proxies /api and /ir to the backend (see vite.config.ts).
VITE_SERVER_URL=
VITE_CDN_URL="http://localhost:9000/tachi-public"
VITE_GIT_REPO="GitHub:zkldi/Tachi"
VITE_EAG_CLIENT_ID=""
VITE_MIN_CLIENT_ID=""
+3 -2
View File
@@ -1,7 +1,8 @@
BROWSER="none"
VITE_TCHIC_MODE="omni"
VITE_SERVER_URL="https://127.0.0.1:8080"
VITE_CDN_URL="http://127.0.0.1:9000/tachi-public"
# Empty = same-origin; Vite proxies /api and /ir to the backend (see vite.config.ts).
VITE_SERVER_URL=
VITE_CDN_URL="http://localhost:9000/tachi-public"
VITE_GIT_REPO="GitHub:zkldi/Tachi"
VITE_EAG_CLIENT_ID=""
VITE_MIN_CLIENT_ID=""
+1 -9
View File
@@ -5,7 +5,7 @@
"type": "module",
"license": "AGPL3",
"scripts": {
"dev": "vite --open",
"dev": "vite",
"build": "vite build",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
@@ -34,29 +34,21 @@
"@fullcalendar/daygrid": "catalog:",
"@fullcalendar/react": "catalog:",
"@nivo/bar": "catalog:",
"@nivo/colors": "catalog:",
"@nivo/core": "catalog:",
"@nivo/line": "catalog:",
"@nivo/tooltip": "catalog:",
"@types/lodash": "catalog:",
"@types/natural-compare": "catalog:",
"@types/react-google-recaptcha": "catalog:",
"csstype": "catalog:",
"d3-scale": "catalog:",
"deepmerge": "catalog:",
"dotenv": "catalog:",
"fast-json-stable-hash": "catalog:",
"formik": "catalog:",
"fs-extra": "catalog:",
"hash.js": "catalog:",
"humanize-duration": "catalog:",
"json-2-csv": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
"nanoid": "catalog:",
"natural-compare": "catalog:",
"pretty-bytes": "catalog:",
"prop-types": "catalog:",
"prudence": "catalog:",
"react": "catalog:",
"react-async": "catalog:",
+40 -7
View File
@@ -5,7 +5,7 @@ import { APIFetchV1 } from "#util/api";
import { HumaniseError } from "#util/humanise-error";
import { HistorySafeGoBack } from "#util/misc";
import { useFormik } from "formik";
import React, { useContext, useRef, useState } from "react";
import React, { useContext, useEffect, useRef, useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
@@ -21,6 +21,35 @@ export default function LoginPage() {
const recaptchaRef = useRef<any>(null);
const [localDevFirstAdminLogin, setLocalDevFirstAdminLogin] = useState<
| { password: string; status: "ready"; username: string }
| { status: "loading" }
| { status: "unavailable" }
| null
>(null);
useEffect(() => {
if (!import.meta.env.VITE_IS_LOCAL_DEV) {
return;
}
setLocalDevFirstAdminLogin({ status: "loading" });
void APIFetchV1<{ password: string; username: string }>("/localdev/first-admin-login")
.then((r) => {
if (r.success && r.body) {
setLocalDevFirstAdminLogin({
status: "ready",
username: r.body.username,
password: r.body.password,
});
} else {
setLocalDevFirstAdminLogin({ status: "unavailable" });
}
})
.catch(() => {
setLocalDevFirstAdminLogin({ status: "unavailable" });
});
}, []);
const performLogin = async (values: {
"!password": string;
captcha: string;
@@ -86,22 +115,26 @@ export default function LoginPage() {
onSubmit: performLogin,
});
const localDevReadyLogin =
localDevFirstAdminLogin?.status === "ready" ? localDevFirstAdminLogin : null;
return (
<LoginPageLayout description={<Description />} heading="Log In">
<Form className="d-flex flex-column gap-4 w-100" onSubmit={formik.handleSubmit}>
{import.meta.env.VITE_IS_LOCAL_DEV && (
{import.meta.env.VITE_IS_LOCAL_DEV && localDevReadyLogin && (
<Alert className="mb-0" variant="info">
<div className="d-flex flex-column gap-2">
<span>
You are in local development mode. You can login as the admin
account.
You are in local development mode. You can log in as the first admin
account (<code>{localDevReadyLogin.username}</code> /{" "}
<code>{localDevReadyLogin.password}</code>).
</span>
<Button
className="align-self-start"
onClick={() =>
void performLogin({
username: "admin",
"!password": "password",
username: localDevReadyLogin.username,
"!password": localDevReadyLogin.password,
captcha: formik.values.captcha,
})
}
@@ -109,7 +142,7 @@ export default function LoginPage() {
type="button"
variant="outline-primary"
>
Log in as admin
Log in as first admin
</Button>
</div>
</Alert>
+1 -7
View File
@@ -17,8 +17,6 @@ try {
throw new Error(`Failed to fetch config -- ${configRes.description}.`);
}
} catch (err) {
const devServerUrl = import.meta.env.VITE_SERVER_URL ?? "";
const showHttpsCertHint = devServerUrl.startsWith("https://");
const statusHelpUrl = ToAbsoluteAPIURLForHelpLink("/status");
document.open();
document.write(`
@@ -47,11 +45,7 @@ try {
<h1><b>Couldn't connect to the server.</b></h1>
<h3>You are in local development mode.</h3>
<ul style="font-size: 2rem;">
<li>${
showHttpsCertHint
? `Have you accepted the HTTPS certificates for <a href="${statusHelpUrl}">the server?</a> If not, the site won't load.`
: `Is the backend running? Try opening <a href="${statusHelpUrl}">the status endpoint</a> in your browser (default dev URL is <code>http://127.0.0.1:8080</code>).`
}</li>
<li>Is the backend running on <code>http://localhost:8080</code>? Try <a href="${statusHelpUrl}">the status endpoint</a>. With the default Vite proxy, the API is also available at <code>http://localhost:3000/api/v1/...</code>.</li>
</ul>
`
: `<h1>Failed to connect!</h1>
+7 -3
View File
@@ -24,8 +24,8 @@ export function ToAPIURL(url: string) {
/**
* When `VITE_SERVER_URL` is unset, `ToAPIURL` is same-origin relative. On the Vite dev
* server, a plain `/api/v1/...` link would hit the SPA and reload `index.html` in a loop.
* For "open in browser" help links, force an absolute backend URL in local dev.
* server, `vite.config` proxies `/api` to the backend so `/api/v1/...` works.
* For "open in browser" help links in local dev, use the page origin (Vite on :3000), not :8080.
*/
export function ToAbsoluteAPIURLForHelpLink(url: string) {
const u = ToAPIURL(url);
@@ -33,7 +33,11 @@ export function ToAbsoluteAPIURLForHelpLink(url: string) {
return u;
}
if (import.meta.env.VITE_IS_LOCAL_DEV) {
return `http://127.0.0.1:8080${u}`;
const origin =
typeof window !== "undefined" && window.location?.origin
? window.location.origin
: "http://localhost:3000";
return `${origin}${u}`;
}
return u;
}
+71
View File
@@ -1,11 +1,74 @@
import { spawn } from "node:child_process";
import react from "@vitejs/plugin-react";
import { config } from "dotenv";
import path from "path";
import { defineConfig } from "vite";
import type { Plugin, PreviewServer, ViteDevServer } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
config();
/**
* Vite's `server.open` / `preview.open` resolve against `resolvedUrls`, which use
* `localhost` for loopback when `host: true` (see vite `resolveServerUrls`). The IDE
* may also open forwarded ports as `localhost`. Opening the OS browser ourselves
* with an explicit loopback URL keeps the address bar on localhost when that matters.
*/
function openLoopbackInBrowserPlugin(): Plugin {
const schedule = (
httpServer: ViteDevServer["httpServer"],
portFallback: number | undefined,
useHttps: boolean,
) => {
if (!httpServer) return;
const run = () => {
const addr = httpServer.address();
const port =
typeof addr === "object" && addr && "port" in addr
? addr.port
: (portFallback ?? 3000);
const protocol = useHttps ? "https" : "http";
const url = `${protocol}://localhost:${port}/`;
if (process.platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else if (process.platform === "win32") {
spawn("cmd", ["/c", "start", "", url], {
detached: true,
stdio: "ignore",
shell: false,
}).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
};
if (httpServer.listening) run();
else httpServer.once("listening", run);
};
return {
name: "open-loopback-explicit",
apply: "serve",
configureServer(server: ViteDevServer) {
return () => {
schedule(
server.httpServer,
server.config.server.port,
Boolean(server.config.server.https),
);
};
},
configurePreviewServer(server: PreviewServer) {
return () => {
schedule(
server.httpServer,
server.config.preview.port,
Boolean(server.config.preview.https),
);
};
},
};
}
export default defineConfig({
build: {
assetsDir: "static",
@@ -25,6 +88,7 @@ export default defineConfig({
},
},
plugins: [
openLoopbackInBrowserPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -49,6 +113,7 @@ root.setAttribute("data-bs-theme", theme);
}),
],
preview: {
open: false,
port: 3000,
},
resolve: {
@@ -65,7 +130,13 @@ root.setAttribute("data-bs-theme", theme);
},
server: {
host: true,
open: false,
port: 3000,
// Same-origin `/api` + `/ir` so local dev can use HTTP + SameSite=Lax session cookies (no HTTPS).
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
"/ir": { target: "http://localhost:8080", changeOrigin: true },
},
watch: {
usePolling: process.env.FORCE_FS_POLLING === "true",
},
+1 -2
View File
@@ -36,14 +36,13 @@
"author": "zk",
"license": "MIT",
"dependencies": {
"@types/express-serve-static-core": "catalog:",
"@types/mongodb": "catalog:",
"deepmerge": "catalog:",
"prudence": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint-config-tachi": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
+51 -47
View File
@@ -1,19 +1,19 @@
import t from "tap";
import { describe, expect, it } from "vitest";
import { allSupportedGameGroups, GetGameGroupConfig, GetGamePTConfig } from "./config";
t.test("#GetGameGroupConfig", (t) => {
for (const game of allSupportedGameGroups) {
// i don't feel *that* strongly about this restriction, but game IDs *definitely*
// can't have things like `:` in them.
t.match(game, /^[a-z]+$/u, "Game IDs must be a-z only.");
describe("#GetGameGroupConfig", () => {
it("defines configs for every supported game group with valid IDs", () => {
for (const game of allSupportedGameGroups) {
// i don't feel *that* strongly about this restriction, but game IDs *definitely*
// can't have things like `:` in them.
expect(game).toMatch(/^[a-z]+$/u);
const conf = GetGameGroupConfig(game);
const conf = GetGameGroupConfig(game);
t.not(conf, undefined, `'${game}' should have a config defined.`);
}
t.end();
expect(conf, `'${game}' should have a config defined.`).toBeDefined();
}
});
});
const BANNED_METRIC_NAMES = [
@@ -28,46 +28,50 @@ const BANNED_METRIC_NAMES = [
"chartCount",
];
t.test("#GetGamePTConfig", (t) => {
for (const game of allSupportedGameGroups) {
const gameConfig = GetGameGroupConfig(game);
describe("#GetGamePTConfig", () => {
it("defines playtype configs with consistent rating algs and metrics", () => {
for (const game of allSupportedGameGroups) {
const gameConfig = GetGameGroupConfig(game);
for (const playtype of gameConfig.playtypes) {
const conf = GetGamePTConfig(game, playtype);
for (const playtype of gameConfig.playtypes) {
const conf = GetGamePTConfig(game, playtype);
t.not(conf, undefined, `'${game}:${playtype}' should have a config defined.`);
t.ok(
conf.scoreRatingAlgs[conf.defaultScoreRatingAlg],
"The default score rating alg should have an implementation.",
);
t.ok(
conf.sessionRatingAlgs[conf.defaultSessionRatingAlg],
"The default session rating alg should have an implementation.",
);
t.ok(
conf.profileRatingAlgs[conf.defaultProfileRatingAlg],
"The default profile rating alg should have an implementation.",
);
if (conf.difficulties.type === "FIXED") {
t.ok(
conf.difficulties.order.includes(conf.difficulties.default),
"The default difficulty should be part of difficultyOrder.",
);
}
for (const metric of Object.keys(conf.scoreRatingAlgs)) {
if (BANNED_METRIC_NAMES.includes(metric)) {
t.fail(`Cannot have a metric called ${metric}. This is a banned metric name.`);
expect(conf, `'${game}:${playtype}' should have a config defined.`).toBeDefined();
if (!conf) {
continue;
}
t.match(metric, /^[a-zA-Z][a-zA-Z0-9]+$/u, `Should be alphanumeric.`);
expect(
conf.scoreRatingAlgs[conf.defaultScoreRatingAlg],
"The default score rating alg should have an implementation.",
).toBeTruthy();
expect(
conf.sessionRatingAlgs[conf.defaultSessionRatingAlg],
"The default session rating alg should have an implementation.",
).toBeTruthy();
expect(
conf.profileRatingAlgs[conf.defaultProfileRatingAlg],
"The default profile rating alg should have an implementation.",
).toBeTruthy();
if (conf.difficulties.type === "FIXED") {
expect(
conf.difficulties.order.includes(conf.difficulties.default),
"The default difficulty should be part of difficultyOrder.",
).toBe(true);
}
for (const metric of Object.keys(conf.scoreRatingAlgs)) {
expect(
BANNED_METRIC_NAMES.includes(metric),
`Cannot have a metric called ${metric}. This is a banned metric name.`,
).toBe(false);
expect(metric, `Should be alphanumeric.`).toMatch(/^[a-zA-Z][a-zA-Z0-9]+$/u);
}
}
}
}
t.end();
});
});
@@ -1,11 +1,11 @@
import t from "tap";
import { describe, expect, it } from "vitest";
import { BMS_TABLES } from "./bms-tables";
t.test("BMS Tables should be unique.", (t) => {
const allKeys = BMS_TABLES.map((e) => `${e.playtype}-${e.prefix}`);
describe("BMS_TABLES", () => {
it("should have unique playtype-prefix keys", () => {
const allKeys = BMS_TABLES.map((e) => `${e.playtype}-${e.prefix}`);
t.strictSame(allKeys, [...new Set(allKeys)], "There should be no duplicate table prefixes.");
t.end();
expect(allKeys).toStrictEqual([...new Set(allKeys)]);
});
});
+7 -7
View File
@@ -1,12 +1,12 @@
import t from "tap";
import { describe, expect, it } from "vitest";
import * as tachiCommon from "./index";
// we just check that we're exporting stuff properly
t.test("Property Checks", (t) => {
t.type(tachiCommon.GetGameGroupConfig, "function");
t.type(tachiCommon.GetGamePTConfig, "function");
t.type(tachiCommon.COLOUR_SET, "object");
t.end();
describe("Property Checks", () => {
it("exports core APIs and constants", () => {
expect(typeof tachiCommon.GetGameGroupConfig).toBe("function");
expect(typeof tachiCommon.GetGamePTConfig).toBe("function");
expect(typeof tachiCommon.COLOUR_SET).toBe("object");
});
});
+8 -4
View File
@@ -2,13 +2,17 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
env: {
NODE_ENV: "test",
},
exclude: [
// Compiled output — not test sources.
"build/**",
// Legacy tap-based tests not yet migrated to vitest.
"src/index.test.ts",
"src/config/config.test.ts",
"src/constants/bms-tables.test.ts",
],
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "build/**"],
},
},
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "tachi-coverage-tools",
"version": "0.1.0",
"private": true,
"description": "Aggregate Vitest v8/Istanbul coverage reports across workspaces.",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"report": "bun run src/report-coverage.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"istanbul-lib-coverage": "catalog:"
},
"devDependencies": {
"@types/istanbul-lib-coverage": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+8
View File
@@ -0,0 +1,8 @@
export { COVERAGE_SOURCES, type CoverageSource } from "./manifest";
export {
loadCoverageMapFromFinal,
metricsFromSummary,
summarizeByTopSrcDir,
type Metrics,
} from "./summary-from-final";
export { buildReport, type CoverageReport, type PackageReport } from "./report-coverage";
+38
View File
@@ -0,0 +1,38 @@
/**
* Registry of Vitest packages that emit `coverage/coverage-final.json` when tests
* run with `--coverage` (see Justfile-test `test-typescript`).
*
* When you add a new workspace with Vitest + v8 coverage, append an entry here
* and (if needed) point `coverageFinal` at that package’s reportsDirectory.
*/
export type CoverageSource = {
/** Short id for `--packages` */
id: string;
/** Root package directory relative to the repo root (e.g. typescript/server) */
packageRoot: string;
/** Istanbul `coverage-final.json` path relative to the repo root */
coverageFinal: string;
};
export const COVERAGE_SOURCES: readonly CoverageSource[] = [
{
id: "server",
packageRoot: "typescript/server",
coverageFinal: "typescript/server/coverage/coverage-final.json",
},
{
id: "bot",
packageRoot: "typescript/bot",
coverageFinal: "typescript/bot/coverage/coverage-final.json",
},
{
id: "rg-stats",
packageRoot: "typescript/rg-stats",
coverageFinal: "typescript/rg-stats/coverage/coverage-final.json",
},
{
id: "common",
packageRoot: "typescript/common",
coverageFinal: "typescript/common/coverage/coverage-final.json",
},
] as const;
@@ -0,0 +1,233 @@
#!/usr/bin/env bun
/**
* Read aggregated Istanbul coverage from Vitest v8 output and print summaries.
*
* Typical workflow:
* just test-typescript # generates coverage under each package’s coverage/
* just coverage-report # this tool
*
* Flags:
* --json machine-readable report on stdout
* --by-dir per top-level src/ subdirectory (server/bot only; needs src layout)
* --packages a,b limit to manifest ids (default: all)
* --min-lines N exit 1 if any selected package is below this line %
* --strict exit 1 if a coverage file is missing
*
* Programmatic: import { buildReport } from "./report-coverage.js" (see buildReport).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { COVERAGE_SOURCES } from "./manifest";
import {
loadCoverageMapFromFinal,
metricsFromSummary,
summarizeByTopSrcDir,
type Metrics,
} from "./summary-from-final";
export type PackageReport = {
id: string;
packageRoot: string;
coverageFinal: string;
present: boolean;
metrics: Metrics | null;
byDir?: Record<string, Metrics>;
};
export type CoverageReport = {
repoRoot: string;
packages: PackageReport[];
};
function pct(n: number): string {
return `${n.toFixed(1)}%`;
}
function row(label: string, m: Metrics): string {
return [
label.padEnd(10),
pct(m.lines.pct).padStart(7),
pct(m.statements.pct).padStart(7),
pct(m.functions.pct).padStart(7),
pct(m.branches.pct).padStart(7),
`${m.lines.covered}/${m.lines.total}`.padStart(12),
].join(" ");
}
function resolveRepoRoot(): string {
// This file lives at typescript/coverage-tools/src/
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
}
export function buildReport(options: {
repoRoot: string;
packageIds: Set<string> | null;
byDir: boolean;
}): CoverageReport {
const { repoRoot, packageIds, byDir } = options;
const packages: PackageReport[] = [];
for (const src of COVERAGE_SOURCES) {
if (packageIds && !packageIds.has(src.id)) {
continue;
}
const finalPath = path.join(repoRoot, src.coverageFinal);
let present = fs.existsSync(finalPath);
let metrics: Metrics | null = null;
let byDirMetrics: Record<string, Metrics> | undefined;
if (present) {
try {
const map = loadCoverageMapFromFinal(finalPath);
metrics = metricsFromSummary(map.getCoverageSummary());
if (byDir) {
byDirMetrics = summarizeByTopSrcDir(map, src.packageRoot, repoRoot);
}
} catch {
present = false;
}
}
packages.push({
id: src.id,
packageRoot: src.packageRoot,
coverageFinal: src.coverageFinal,
present,
metrics,
byDir: byDirMetrics,
});
}
return { repoRoot, packages };
}
function printHuman(report: CoverageReport): void {
console.log("Coverage (Vitest v8 / Istanbul)\n");
console.log(
[
"".padEnd(10),
"Lines".padStart(7),
"Stmt".padStart(7),
"Funcs".padStart(7),
"Branch".padStart(7),
"Lines hit".padStart(12),
].join(" "),
);
console.log("-".repeat(62));
for (const p of report.packages) {
if (!p.present || !p.metrics) {
console.log(`${p.id.padEnd(10)} (no data — run: just test-typescript)`);
continue;
}
console.log(row(p.id, p.metrics));
if (p.byDir && Object.keys(p.byDir).length > 0) {
const names = Object.keys(p.byDir).sort((a, b) => a.localeCompare(b));
for (const name of names) {
console.log(row(` ${name}`, p.byDir[name]!));
}
}
}
console.log("");
}
function main(): void {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
json: { type: "boolean", default: false },
"by-dir": { type: "boolean", default: false },
"min-lines": { type: "string" },
packages: { type: "string" },
strict: { type: "boolean", default: false },
help: { type: "boolean", default: false, short: "h" },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
console.log(`Usage: bun run src/report-coverage.ts [options]
Options:
--json Emit JSON (CoverageReport shape)
--by-dir Break down each package by top-level directory under src/
--packages a,b Only include manifest ids (default: all in manifest.ts)
--min-lines N Fail if any included package line coverage < N
--strict Fail if any expected coverage file is missing or unreadable
`);
process.exit(0);
}
const repoRoot = resolveRepoRoot();
let packageIds: Set<string> | null = null;
if (values.packages) {
packageIds = new Set(
values.packages
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
const report = buildReport({
repoRoot,
packageIds,
byDir: values["by-dir"] ?? false,
});
let exitCode = 0;
if (values.strict) {
for (const p of report.packages) {
if (!p.present || !p.metrics) {
exitCode = 1;
}
}
}
const minLines = values["min-lines"] ? Number(values["min-lines"]) : null;
if (minLines !== null && !Number.isFinite(minLines)) {
console.error("Invalid --min-lines");
process.exit(1);
}
if (minLines !== null) {
for (const p of report.packages) {
if (p.metrics && p.metrics.lines.pct < minLines) {
exitCode = 1;
}
}
}
if (values.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printHuman(report);
if (exitCode !== 0) {
if (values.strict) {
console.error("Strict mode: missing or invalid coverage for one or more packages.");
}
if (minLines !== null) {
console.error(
`Line coverage below --min-lines ${minLines} for one or more packages.`,
);
}
}
}
process.exit(exitCode);
}
function isMainModule(): boolean {
const entry = process.argv[1];
if (!entry) {
return false;
}
return path.resolve(entry) === path.resolve(fileURLToPath(import.meta.url));
}
if (isMainModule()) {
main();
}
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
import { createCoverageMap } from "istanbul-lib-coverage";
import type { CoverageMap, CoverageSummary } from "istanbul-lib-coverage";
export type Metrics = {
lines: { pct: number; covered: number; total: number };
statements: { pct: number; covered: number; total: number };
functions: { pct: number; covered: number; total: number };
branches: { pct: number; covered: number; total: number };
};
export function metricsFromSummary(s: CoverageSummary): Metrics {
return {
lines: {
pct: s.lines.pct,
covered: s.lines.covered,
total: s.lines.total,
},
statements: {
pct: s.statements.pct,
covered: s.statements.covered,
total: s.statements.total,
},
functions: {
pct: s.functions.pct,
covered: s.functions.covered,
total: s.functions.total,
},
branches: {
pct: s.branches.pct,
covered: s.branches.covered,
total: s.branches.total,
},
};
}
export function loadCoverageMapFromFinal(coverageFinalPath: string): CoverageMap {
const raw = fs.readFileSync(coverageFinalPath, "utf8");
const data = JSON.parse(raw) as Parameters<CoverageMap["merge"]>[0];
const map = createCoverageMap();
map.merge(data);
return map;
}
/** Group file paths by top-level segment under `src/` (e.g. actions, server, utils). */
export function summarizeByTopSrcDir(
map: CoverageMap,
packageRoot: string,
repoRoot: string,
): Record<string, Metrics> {
const srcPrefix = path.join(repoRoot, packageRoot, "src") + path.sep;
const groups: Record<string, CoverageMap> = {};
for (const file of map.files()) {
if (!file.startsWith(srcPrefix)) {
continue;
}
const rel = file.slice(srcPrefix.length);
const top = rel.split(path.sep)[0];
if (!top || top.startsWith("@")) {
continue;
}
if (!groups[top]) {
groups[top] = createCoverageMap();
}
const fc = map.fileCoverageFor(file);
groups[top].merge({ [file]: fc.data });
}
const out: Record<string, Metrics> = {};
for (const [name, g] of Object.entries(groups)) {
out[name] = metricsFromSummary(g.getCoverageSummary());
}
return out;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -21,7 +21,6 @@
"bench": "node -e \"process.exit(0)\""
},
"dependencies": {
"kysely": "catalog:",
"pg": "catalog:"
},
"devDependencies": {
+1 -2
View File
@@ -12,8 +12,7 @@
"bench": "vitest bench --passWithNoTests"
},
"dependencies": {
"kysely": "catalog:",
"pg": "catalog:"
"kysely": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
+4
View File
@@ -2,6 +2,7 @@
// This file is automatically generated by Kanel. Do not modify manually.
export { type priv_svc_cg_card_info_service, type default as PrivSvcCgCardInfoTable, type PrivSvcCgCardInfo, type NewPrivSvcCgCardInfo, type PrivSvcCgCardInfoUpdate } from './public/PrivSvcCgCardInfo';
export { type score_rederive_chart_id, type default as ScoreRederiveTable, type ScoreRederive, type NewScoreRederive, type ScoreRederiveUpdate } from './public/ScoreRederive';
export { type class_achievement_row_id, type default as ClassAchievementTable, type ClassAchievement, type NewClassAchievement, type ClassAchievementUpdate } from './public/ClassAchievement';
export { type account_id, type default as AccountTable, type Account, type NewAccount, type AccountUpdate } from './public/Account';
export { type import_tracker_import_id, type default as ImportTrackerTable, type ImportTracker, type NewImportTracker, type ImportTrackerUpdate } from './public/ImportTracker';
@@ -33,6 +34,7 @@ export { type session_id, type default as SessionTable, type Session, type NewSe
export { type priv_password_reset_token_token, type default as PrivPasswordResetTokenTable, type PrivPasswordResetToken, type NewPrivPasswordResetToken, type PrivPasswordResetTokenUpdate } from './public/PrivPasswordResetToken';
export { type song_id, type default as SongTable, type Song, type NewSong, type SongUpdate } from './public/Song';
export { type account_username_change_row_id, type default as AccountUsernameChangeTable, type AccountUsernameChange, type NewAccountUsernameChange, type AccountUsernameChangeUpdate } from './public/AccountUsernameChange';
export { type pb_dirty_user_id, type pb_dirty_chart_id, type default as PbDirtyTable, type PbDirty, type NewPbDirty, type PbDirtyUpdate } from './public/PbDirty';
export { type default as MigrationTable, type Migration, type NewMigration, type MigrationUpdate } from './public/Migration';
export { type action_row_id, type default as ActionTable, type Action, type NewAction, type ActionUpdate } from './public/Action';
export { type priv_svc_kai_auth_token_service, type default as PrivSvcKaiAuthTokenTable, type PrivSvcKaiAuthToken, type NewPrivSvcKaiAuthToken, type PrivSvcKaiAuthTokenUpdate } from './public/PrivSvcKaiAuthToken';
@@ -71,5 +73,7 @@ export { type default as Game } from './public/Game';
export { type default as ActionResult } from './public/ActionResult';
export { type default as GameGroup } from './public/GameGroup';
export { type default as ImportType } from './public/ImportType';
export { type enqueue_pb_dirty_params } from './public/enqueue_pb_dirty';
export { type enqueue_score_rederive_params } from './public/enqueue_score_rederive';
export { type default as PublicSchema } from './public/PublicSchema';
export { type default as Database } from './Database';
@@ -29,6 +29,8 @@ export default interface ChartTable {
versions: ColumnType<string[], string[], string[]>;
data: ColumnType<unknown, unknown, unknown>;
derivation_checksum: ColumnType<string | null, string | null, string | null>;
}
export type Chart = Selectable<ChartTable>;
@@ -0,0 +1,25 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.pb_dirty */
export type pb_dirty_user_id = number;
/** Identifier type for public.pb_dirty */
export type pb_dirty_chart_id = string;
/** Represents the table public.pb_dirty */
export default interface PbDirtyTable {
user_id: ColumnType<pb_dirty_user_id, pb_dirty_user_id, pb_dirty_user_id>;
chart_id: ColumnType<pb_dirty_chart_id, pb_dirty_chart_id, pb_dirty_chart_id>;
enqueued_at: ColumnType<string, string | undefined, string>;
}
export type PbDirty = Selectable<PbDirtyTable>;
export type NewPbDirty = Insertable<PbDirtyTable>;
export type PbDirtyUpdate = Updateable<PbDirtyTable>;
@@ -2,6 +2,7 @@
// This file is automatically generated by Kanel. Do not modify manually.
import type { default as PrivSvcCgCardInfoTable } from './PrivSvcCgCardInfo';
import type { default as ScoreRederiveTable } from './ScoreRederive';
import type { default as ClassAchievementTable } from './ClassAchievement';
import type { default as AccountTable } from './Account';
import type { default as ImportTrackerTable } from './ImportTracker';
@@ -33,6 +34,7 @@ import type { default as SessionTable } from './Session';
import type { default as PrivPasswordResetTokenTable } from './PrivPasswordResetToken';
import type { default as SongTable } from './Song';
import type { default as AccountUsernameChangeTable } from './AccountUsernameChange';
import type { default as PbDirtyTable } from './PbDirty';
import type { default as MigrationTable } from './Migration';
import type { default as ActionTable } from './Action';
import type { default as PrivSvcKaiAuthTokenTable } from './PrivSvcKaiAuthToken';
@@ -69,6 +71,8 @@ import type { default as ChartLeaderboardTable } from './ChartLeaderboard';
export default interface PublicSchema {
priv_svc_cg_card_info: PrivSvcCgCardInfoTable;
score_rederive: ScoreRederiveTable;
class_achievement: ClassAchievementTable;
account: AccountTable;
@@ -131,6 +135,8 @@ export default interface PublicSchema {
account_username_change: AccountUsernameChangeTable;
pb_dirty: PbDirtyTable;
_migration: MigrationTable;
action: ActionTable;
@@ -0,0 +1,20 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.score_rederive */
export type score_rederive_chart_id = string;
/** Represents the table public.score_rederive */
export default interface ScoreRederiveTable {
chart_id: ColumnType<score_rederive_chart_id, score_rederive_chart_id, score_rederive_chart_id>;
enqueued_at: ColumnType<string, string | undefined, string>;
}
export type ScoreRederive = Selectable<ScoreRederiveTable>;
export type NewScoreRederive = Insertable<ScoreRederiveTable>;
export type ScoreRederiveUpdate = Updateable<ScoreRederiveTable>;
@@ -0,0 +1,5 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
export interface enqueue_pb_dirty_params {
}
@@ -0,0 +1,5 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
export interface enqueue_score_rederive_params {
}
@@ -22,7 +22,6 @@
"bun": ">=1.2.0"
},
"dependencies": {
"commander": "catalog:",
"tachi-common": "workspace:*"
},
"imports": {
+22
View File
@@ -0,0 +1,22 @@
import configTachi from "eslint-config-tachi";
export default [
...configTachi.base,
configTachi.node,
{
ignores: ["build/**", "node_modules/**", "tap-snapshots/**"],
},
{
files: ["src/**/*.ts"],
name: "rg-stats/vendored-style",
rules: {
"@typescript-eslint/consistent-type-imports": "off",
"perfectionist/sort-exports": "off",
// Upstream rg-stats keeps intentional ordering (e.g. score tiers, lamp unions).
"perfectionist/sort-imports": "off",
"perfectionist/sort-maps": "off",
"perfectionist/sort-object-types": "off",
"perfectionist/sort-union-types": "off",
},
},
];
+62
View File
@@ -0,0 +1,62 @@
{
"name": "rg-stats",
"version": "1.2.1",
"description": "A library for calculating various rhythm game stats.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./js/util/types": {
"types": "./src/util/types.ts",
"default": "./src/util/types.ts"
}
},
"scripts": {
"test": "vitest run --coverage",
"snapshot": "vitest run -u",
"snap": "vitest run -u",
"build": "tsgo -p tsconfig.build.json",
"prepublishOnly": "vitest run --coverage && tsgo -p tsconfig.build.json",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"publishConfig": {
"main": "./build/index.js",
"exports": {
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
},
"./js/util/types": {
"types": "./build/util/types.d.ts",
"default": "./build/util/types.js"
}
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/zkldi/rg-stats.git"
},
"keywords": [
"rhythm",
"game",
"stats",
"math"
],
"author": "zkldi",
"license": "MIT",
"bugs": {
"url": "https://github.com/zkldi/rg-stats/issues"
},
"homepage": "https://github.com/zkldi/rg-stats#readme",
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint-config-tachi": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CHUNITHM Rating Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`;
exports[`CHUNITHM Rating Validation Tests > Should throw if your score is >= 1.01million. 1`] = `"Invalid input, Score cannot be greater than 1.01Million. score=1010001."`;
exports[`CHUNITHM Rating Validation Tests > Should throw if your score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DDR Flare Validation Tests > Should throw if chart level is greater than 19. 1`] = `"Invalid input, Internal chart level cannot be greater than 19. level=20."`;
exports[`DDR Flare Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative or zero. level=-1."`;
exports[`DDR Flare Validation Tests > Should throw if flare level is greater than 10. 1`] = `"Invalid input, Flare level cannot be greater than 10. level=11."`;
exports[`DDR Flare Validation Tests > Should throw if flare level is negative. 1`] = `"Invalid input, Flare level cannot be negative. level=-1."`;
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GITADORA Inverse Skill Validation Tests > Should throw if level provided was negative 1`] = `"Invalid input, Chart Level cannot be negative. level=-1."`;
exports[`GITADORA Inverse Skill Validation Tests > Should throw if skill provided was negative 1`] = `"Invalid input, Skill cannot be negative. skill=-1."`;
exports[`GITADORA Inverse Skill Validation Tests > Should throw if the skill provided was not possible on this chart. 1`] = `"Invalid input, A skill level of 9000 is not possible on a chart with level 1. skill=9000, level=1."`;
exports[`GITADORA Skill Validation Tests > Should throw if level provided was negative 1`] = `"Invalid input, Chart Level cannot be negative. level=-1."`;
exports[`GITADORA Skill Validation Tests > Should throw if skill provided was negative 1`] = `"Invalid input, Score Percent cannot be negative. scorePercent=-1."`;
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ITG Highest Unbroken Tests > Should throw if bpms and notes don't have same length 1`] = `"Invalid input, Notes Per Measure and NPS Per Measure didn't have the same length. npsLen=0, notesLen=1."`;
exports[`ITG Highest Unbroken Tests > Should throw if died at is negative 1`] = `"Invalid input, DiedAt must be positive. diedAt=-1."`;
exports[`ITG Highest Unbroken Tests > Should throw if measures is 0 1`] = `"Invalid input, Measures must be greater than 1. measures=0."`;
exports[`ITG Highest Unbroken Tests > Should throw if measures is 1 1`] = `"Invalid input, Measures must be greater than 1. measures=1."`;
exports[`ITG Highest Unbroken Tests > Should throw if measures is negative 1`] = `"Invalid input, Measures must be greater than 1. measures=-1."`;
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Inverse Jubility Tests > Should throw if the requested jubility is not possible on a chart of that level. 1`] = `"Invalid input, A jubility of 9000 is not possible on a chart with level 1. jubility=9000, level=1."`;
exports[`Jubility Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Level cannot be negative. level=-1."`;
exports[`Jubility Validation Tests > Should throw if music rate is > 120. 1`] = `"Invalid input, MusicRate cannot be greater than 120. musicRate=120.1."`;
exports[`Jubility Validation Tests > Should throw if music rate is negative. 1`] = `"Invalid input, MusicRate cannot be negative. musicRate=-1."`;
exports[`Jubility Validation Tests > Should throw if score is > 1million. 1`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`;
exports[`Jubility Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`maimai Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative. level=-1."`;
exports[`maimai Rate Validation Tests > Should throw if max score is greater than 104%. 1`] = `"Invalid input, Max score cannot be greater than 104%. maxScore=104.1."`;
exports[`maimai Rate Validation Tests > Should throw if score is greater than max score. 1`] = `"Invalid input, Score cannot be greater than max score. score=101.5, maxScore=100.68."`;
exports[`maimai Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`maimai DX Rate Validation Tests > Should throw if lamp is ALL PERFECT but score is below 100%. 1`] = `"Invalid input, Cannot have an ALL PERFECT without at least 100%. score=99.9, lamp="ALL PERFECT"."`;
exports[`maimai DX Rate Validation Tests > Should throw if lamp is ALL PERFECT+ but score is not 101%. 1`] = `"Invalid input, Cannot have an ALL PERFECT+ without 101%. score=100.5, lamp="ALL PERFECT+"."`;
exports[`maimai DX Rate Validation Tests > Should throw if lamp is CLEAR but score is below 80%. 1`] = `"Invalid input, A score of <80% should not be a CLEAR. score=79, lamp="CLEAR"."`;
exports[`maimai DX Rate Validation Tests > Should throw if lamp is FAILED but score is above 80%. 1`] = `"Invalid input, A score of >=80% should not be a FAILED. score=81, lamp="FAILED"."`;
exports[`maimai DX Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Internal chart level cannot be negative. level=-1."`;
exports[`maimai DX Rate Validation Tests > Should throw if score is 101% but lamp is not ALL PERFECT+ 1`] = `"Invalid input, A score of 101% should be an ALL PERFECT+. score=101, lamp="ALL PERFECT"."`;
exports[`maimai DX Rate Validation Tests > Should throw if score is greater than 101%. 1`] = `"Invalid input, Score cannot be greater than 101%. score=101.5."`;
exports[`maimai DX Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if your score is >= 1.01million. 1`] = `"Invalid input, Technical score cannot be greater than 1.01Million. technicalScore=1010001."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Classic should throw if your score is negative. 1`] = `"Invalid input, Technical score cannot be negative. technicalScore=-1."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if star number is negative. 1`] = `"Invalid input, Invalid number of stars. stars=-1."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Platinum should throw if stars > 6. 1`] = `"Invalid input, Invalid number of stars. stars=7."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. internalChartLevel=-1."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if lamp is ALL BREAK+ without 1.01M. 1`] = `"Invalid input, Invalid AB+. fullBell="true", noteLamp="ALL BREAK+"."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is >= 1.01million. 1`] = `"Invalid input, Technical score cannot be greater than 1.01Million. technicalScore=1010001."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is 1.01M without ALL BREAK+. 1`] = `"Invalid input, Invalid AB+. fullBell="true", noteLamp="ALL BREAK"."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is 1.01M without FULL BELL. 1`] = `"Invalid input, Invalid AB+. fullBell="false", noteLamp="ALL BREAK+"."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is a LOSS FULL BELL. 1`] = `"Invalid input, Cannot have a LOSS FULL BELL. noteLamp="LOSS"."`;
exports[`O.N.G.E.K.I. Rating Validation Tests > Refresh should throw if your score is negative. 1`] = `"Invalid input, Technical score cannot be negative. technicalScore=-1."`;
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Pop'n Class Points Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`Pop'n Class Points Validation Tests > Should throw if lamp is invalid. 1`] = `"Unknown lamp of FOO passed to pop'n Class Points calculations. Expected any of FAILED, EASY CLEAR, CLEAR, FULL COMBO, PERFECT."`;
exports[`Pop'n Class Points Validation Tests > Should throw if score is > 100k. 1`] = `"Invalid input, Score cannot be better than 100k. score=100001."`;
exports[`Pop'n Class Points Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
exports[`Pop'n Inverse Class Points Validation Tests > Should throw if chart level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`Pop'n Inverse Class Points Validation Tests > Should throw if the provided class points are impossible to achieve given the other constraints. 1`] = `"Invalid input, 9000 class points is not achievable on a chart with level 1 and lamp FAILED. classPoints=9000, level=1, lamp="FAILED"."`;
@@ -0,0 +1,51 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BPI Validation Tests > Should throw if KAVG == WR 1`] = `"Invalid input, BPI is undefined for the case where Kaiden Average equals the World Record. kaidenAverage=120, worldRecord=120."`;
exports[`BPI Validation Tests > Should throw if KAVG > WR 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=125, worldRecord=120."`;
exports[`BPI Validation Tests > Should throw if KAVG is not an integer. 1`] = `"Invalid input, Kaiden Average was not an integer. kaidenAverage=15.5."`;
exports[`BPI Validation Tests > Should throw if Kaiden Average is negative. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=-1."`;
exports[`BPI Validation Tests > Should throw if MAX is negative. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=-1."`;
exports[`BPI Validation Tests > Should throw if MAX is not an integer. 1`] = `"Invalid input, MAX was not an integer. max=15.5."`;
exports[`BPI Validation Tests > Should throw if WR > MAX 1`] = `"Invalid input, World Record was greater than MAX. worldRecord=130, max=120."`;
exports[`BPI Validation Tests > Should throw if WR is negative. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=-1."`;
exports[`BPI Validation Tests > Should throw if WR is not an integer. 1`] = `"Invalid input, World Record was not an integer. worldRecord=15.5."`;
exports[`BPI Validation Tests > Should throw if your score > MAX 1`] = `"Invalid input, Provided EX was greater than MAX. yourEx=130, max=120."`;
exports[`BPI Validation Tests > Should throw if your score is negative. 1`] = `"Invalid input, Provided EX was negative. yourEx=-1."`;
exports[`BPI Validation Tests > Should throw if your score is not an integer. 1`] = `"Invalid input, Provided EX was not an integer. yourEx=15.5."`;
exports[`InverseBPI Validation Tests > BPI less than -15 should throw an error. 1`] = `"Invalid input, BPI must be greater than or equal to -15. bpi=-16."`;
exports[`InverseBPI Validation Tests > Should throw if KAVG == WR 1`] = `"Invalid input, BPI is undefined for the case where Kaiden Average equals the World Record. kaidenAverage=120, worldRecord=120."`;
exports[`InverseBPI Validation Tests > Should throw if KAVG > WR 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=125, worldRecord=120."`;
exports[`InverseBPI Validation Tests > Should throw if KAVG is not an integer. 1`] = `"Invalid input, Kaiden Average was not an integer. kaidenAverage=15.5."`;
exports[`InverseBPI Validation Tests > Should throw if Kaiden Average is 0. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=0."`;
exports[`InverseBPI Validation Tests > Should throw if Kaiden Average is negative. 1`] = `"Invalid input, Kaiden Average was negative or zero. kaidenAverage=-1."`;
exports[`InverseBPI Validation Tests > Should throw if MAX is 0. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=0."`;
exports[`InverseBPI Validation Tests > Should throw if MAX is negative. 1`] = `"Invalid input, Kaiden Average was greater than MAX. kaidenAverage=100, max=-1."`;
exports[`InverseBPI Validation Tests > Should throw if MAX is not an integer. 1`] = `"Invalid input, MAX was not an integer. max=15.5."`;
exports[`InverseBPI Validation Tests > Should throw if WR > MAX 1`] = `"Invalid input, World Record was greater than MAX. worldRecord=130, max=120."`;
exports[`InverseBPI Validation Tests > Should throw if WR is 0. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=0."`;
exports[`InverseBPI Validation Tests > Should throw if WR is negative. 1`] = `"Invalid input, Kaiden Average was greater than WR. kaidenAverage=100, worldRecord=-1."`;
exports[`InverseBPI Validation Tests > Should throw if WR is not an integer. 1`] = `"Invalid input, World Record was not an integer. worldRecord=15.5."`;
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InverseVF5 Tests > Should throw if the volforce is impossible to achieve with this lamp/level. 1`] = `"Invalid input, A VF5 of 900 is not possible on a chart with level 1. vf5=900, level=1."`;
exports[`InverseVF6 Tests > Should throw if the volforce is impossible to achieve with this lamp/level. 1`] = `"Invalid input, A VF6 of 900 is not possible on a chart with level 1. vf6=900, level=1."`;
exports[`VF4 Validation Tests > Should throw if score is greater than 10 million. 1`] = `"Invalid input, Score cannot be greater than 10million. score=10000001."`;
exports[`VF4 Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
exports[`VF4 Validation Tests > Should throw if the VF4 requested as an inversion is impossible. 1`] = `"Invalid input, A VF4 of 9000 is not possible on a chart with level 20. vf4=9000, level=20."`;
@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`WACCA Inverse Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`WACCA Inverse Rate Validation Tests > Should throw if level is negative. 2`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`WACCA Inverse Rate Validation Tests > Should throw if rate is impossible. 1`] = `"Invalid input, A rate of 100 is not possible on a chart of level 1. rate=100, level=1."`;
exports[`WACCA Inverse Rate Validation Tests > Should throw if rate is impossible. 2`] = `"Invalid input, A rate of 100 is not possible on a chart of level 1. rate=100, level=1."`;
exports[`WACCA Rate Validation Tests > Should throw if level is negative. 1`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`WACCA Rate Validation Tests > Should throw if level is negative. 2`] = `"Invalid input, Chart level cannot be negative. level=-1."`;
exports[`WACCA Rate Validation Tests > Should throw if score is greater than 1million. 1`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`;
exports[`WACCA Rate Validation Tests > Should throw if score is greater than 1million. 2`] = `"Invalid input, Score cannot be greater than 1million. score=1000001."`;
exports[`WACCA Rate Validation Tests > Should throw if score is negative. 1`] = `"Invalid input, Score cannot be negative. score=-1."`;
exports[`WACCA Rate Validation Tests > Should throw if score is negative. 2`] = `"Invalid input, Score cannot be negative. score=-1."`;
@@ -0,0 +1,46 @@
import { expect, test } from "vitest";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate } from "./chunithm-rating";
test("CHUNITHM Rating Tests", () => {
const LEVEL = 12.5;
expect(calculate(1_010_000, LEVEL)).toBe(LEVEL + 2.15);
expect(calculate(1_007_500, LEVEL)).toBe(LEVEL + 2);
expect(calculate(1_005_000, LEVEL)).toBe(LEVEL + 1.5);
expect(calculate(1_000_000, LEVEL)).toBe(LEVEL + 1);
expect(calculate(975_000, LEVEL)).toBe(LEVEL);
expect(calculate(900_000, LEVEL)).toBe(LEVEL - 5);
expect(calculate(800_000, LEVEL)).toBe(3.75);
expect(calculate(500_000, LEVEL)).toBe(0);
expect(calculate(0, LEVEL)).toBe(0);
expect(calculate(987_000, LEVEL)).toBe(12.98);
expect(calculate(1_008_000, LEVEL)).toBe(14.55);
expect(calculate(1_003_000, LEVEL)).toBe(13.8);
expect(calculate(999_000, LEVEL)).toBe(13.46);
expect(calculate(980_000, LEVEL)).toBe(12.7);
expect(calculate(950_000, LEVEL)).toBe(10.83);
expect(calculate(810_000, LEVEL)).toBe(4.12);
expect(calculate(600_000, LEVEL)).toBe(1.25);
expect(calculate(50_000, LEVEL)).toBe(0);
});
test("CHUNITHM Rating Edge Cases", () => {
expect(
calculate(1_010_000, 0),
"A perfect score on a chart with level 0 should be valid, and worth 0 + 2.15.",
).toBe(2.15);
expect(calculate(0, 12.5), "A score of 0 should be worth 0.").toBe(0);
expect(calculate(0, 0), "A score of 0 on a chart with level 0 should be worth 0.").toBe(0);
});
test("CHUNITHM Rating Validation Tests", () => {
expectThrowsSnapshot(() => calculate(-1, 12.5), "Should throw if your score is negative.");
expectThrowsSnapshot(
() => calculate(1_010_001, 12.5),
"Should throw if your score is >= 1.01million.",
);
expectThrowsSnapshot(() => calculate(900_000, -1), "Should throw if chart level is negative.");
});
@@ -0,0 +1,38 @@
import { ThrowIf } from "../util/throw-if";
/**
* Calculates the rating for a CHUNITHM score.
* This is accurate up to SUN.
*
* @param score - The score the user got. This is a value between 0 and 1.01million.
* @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally.
*/
export function calculate(score: number, internalChartLevel: number) {
const levelBase = internalChartLevel * 100;
ThrowIf(score > 1_010_000, "Score cannot be greater than 1.01Million.", { score });
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", { internalChartLevel });
let val = 0;
if (score >= 1_009_000) {
val = levelBase + 215;
} else if (score >= 1_007_500) {
val = levelBase + 200 + (score - 1_007_500) / 100;
} else if (score >= 1_005_000) {
val = levelBase + 150 + ((score - 1_005_000) * 10) / 500;
} else if (score >= 1_000_000) {
val = levelBase + 100 + ((score - 1_000_000) * 5) / 500;
} else if (score >= 975_000) {
val = levelBase + ((score - 975_000) * 2) / 500;
} else if (score >= 900_000) {
val = levelBase - 500 + ((score - 900_000) * 2) / 300;
} else if (score >= 800_000) {
val = (levelBase - 500) / 2 + ((score - 800_000) * ((levelBase - 500) / 2)) / 100_000;
} else if (score >= 500_000) {
val = (((levelBase - 500) / 2) * (score - 500_000)) / 300_000;
}
return Math.max(Math.floor(val) / 100, 0);
}
@@ -0,0 +1,71 @@
import { expect, test } from "vitest";
import { TestCase } from "../test-utils/test-case";
import { calculate, inverse } from "./curator-skill";
const testCases = [
[980_740, 16, 1569],
[975_531, 16, 1560],
[971_879, 16, 1555],
[968_195, 16, 1549],
[964_310, 16, 1542],
[959_301, 16, 1534],
[950_871, 16, 1521],
[998_269, 15, 1497],
[996_327, 15, 1494],
[995_580, 15, 1493],
[995_690, 15, 1493],
[994_676, 15, 1492],
[992_261, 15, 1488],
[992_304, 15, 1488],
[992_365, 15, 1488],
[992_162, 15, 1488],
[991_697, 15, 1487],
[991_488, 15, 1487],
[990_949, 15, 1486],
[990_748, 15, 1486],
];
test("MUSECA Curator Skill Tests", () => {
function MakeTestCase(score: number, level: number, skill: number): TestCase {
return () =>
expect(
calculate(score, level),
`A score of ${score} on a level ${level} chart should be worth ${skill} skill.`,
).toBe(skill);
}
for (const testCase of testCases) {
MakeTestCase(testCase[0], testCase[1], testCase[2])();
}
});
test("MUSECA Inverse Curator Skill Tests", () => {
function MakeTestCase(score: number, level: number, skill: number): TestCase {
return () =>
expect(
inverse(skill, level),
`A skill of ${skill} on a level ${level} chart should need ${score} score.`,
).toBe(score);
}
const inverseTestCases = [
[980_625, 16, 1569],
[975_000, 16, 1560],
[971_875, 16, 1555],
[968_125, 16, 1549],
[963_750, 16, 1542],
[958_750, 16, 1534],
[950_625, 16, 1521],
[998_000, 15, 1497],
[996_000, 15, 1494],
[995_334, 15, 1493],
[994_667, 15, 1492],
[992_000, 15, 1488],
[991_334, 15, 1487],
[990_667, 15, 1486],
];
for (const testCase of inverseTestCases) {
MakeTestCase(testCase[0], testCase[1], testCase[2])();
}
});
@@ -0,0 +1,35 @@
import { ThrowIf } from "../util/throw-if";
/**
* Calculate MUSECA "Curator Skill" for a score.
*
* @param score - The score to calculate the skill rating for.
* @param chartLevel - The level for this chart. Note that some charts are considered
* to be level 16s, and you should pass 16 to this function instead.
*/
export function calculate(score: number, chartLevel: number): number {
ThrowIf(score > 1_000_000, "Score cannot be greater than 1 million.", { score });
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(chartLevel, "Chart level cannot be negative.", { chartLevel });
return Math.floor(chartLevel * (score / 10_000));
}
/**
* Given a curator skill value, return the score necessary to get that skill
* on a chart of the provided level. Throws if this is not possible.
*
* @param skill - The curator skill value to invert.
* @param chartLevel - The difficulty of chart you'd want to achieve this skill value on.
*/
export function inverse(skill: number, chartLevel: number): number {
const score = (skill / chartLevel) * 10_000;
ThrowIf(
score >= 1_000_000,
`A skill level of ${skill} is not possible on a chart with level ${chartLevel}.`,
{ skill, chartLevel },
);
return Math.ceil(score);
}
@@ -0,0 +1,239 @@
import { expect, test } from "vitest";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate } from "./ddr-flare";
const testCases = [
[1, 0, 145],
[1, 1, 153],
[1, 2, 162],
[1, 3, 171],
[1, 4, 179],
[1, 5, 188],
[1, 6, 197],
[1, 7, 205],
[1, 8, 214],
[1, 9, 223],
[1, 10, 232],
[2, 0, 155],
[2, 1, 164],
[2, 2, 173],
[2, 3, 182],
[2, 4, 192],
[2, 5, 201],
[2, 6, 210],
[2, 7, 220],
[2, 8, 229],
[2, 9, 238],
[2, 10, 248],
[3, 0, 170],
[3, 1, 180],
[3, 2, 190],
[3, 3, 200],
[3, 4, 210],
[3, 5, 221],
[3, 6, 231],
[3, 7, 241],
[3, 8, 251],
[3, 9, 261],
[3, 10, 272],
[4, 0, 185],
[4, 1, 196],
[4, 2, 207],
[4, 3, 218],
[4, 4, 229],
[4, 5, 240],
[4, 6, 251],
[4, 7, 262],
[4, 8, 273],
[4, 9, 284],
[4, 10, 296],
[5, 0, 205],
[5, 1, 217],
[5, 2, 229],
[5, 3, 241],
[5, 4, 254],
[5, 5, 266],
[5, 6, 278],
[5, 7, 291],
[5, 8, 303],
[5, 9, 315],
[5, 10, 328],
[6, 0, 230],
[6, 1, 243],
[6, 2, 257],
[6, 3, 271],
[6, 4, 285],
[6, 5, 299],
[6, 6, 312],
[6, 7, 326],
[6, 8, 340],
[6, 9, 354],
[6, 10, 368],
[7, 0, 255],
[7, 1, 270],
[7, 2, 285],
[7, 3, 300],
[7, 4, 316],
[7, 5, 331],
[7, 6, 346],
[7, 7, 362],
[7, 8, 377],
[7, 9, 392],
[7, 10, 408],
[8, 0, 290],
[8, 1, 307],
[8, 2, 324],
[8, 3, 342],
[8, 4, 359],
[8, 5, 377],
[8, 6, 394],
[8, 7, 411],
[8, 8, 429],
[8, 9, 446],
[8, 10, 464],
[9, 0, 335],
[9, 1, 355],
[9, 2, 375],
[9, 3, 395],
[9, 4, 415],
[9, 5, 435],
[9, 6, 455],
[9, 7, 475],
[9, 8, 495],
[9, 9, 515],
[9, 10, 536],
[10, 0, 400],
[10, 1, 424],
[10, 2, 448],
[10, 3, 472],
[10, 4, 496],
[10, 5, 520],
[10, 6, 544],
[10, 7, 568],
[10, 8, 592],
[10, 9, 616],
[10, 10, 640],
[11, 0, 465],
[11, 1, 492],
[11, 2, 520],
[11, 3, 548],
[11, 4, 576],
[11, 5, 604],
[11, 6, 632],
[11, 7, 660],
[11, 8, 688],
[11, 9, 716],
[11, 10, 744],
[12, 0, 510],
[12, 1, 540],
[12, 2, 571],
[12, 3, 601],
[12, 4, 632],
[12, 5, 663],
[12, 6, 693],
[12, 7, 724],
[12, 8, 754],
[12, 9, 785],
[12, 10, 816],
[13, 0, 545],
[13, 1, 577],
[13, 2, 610],
[13, 3, 643],
[13, 4, 675],
[13, 5, 708],
[13, 6, 741],
[13, 7, 773],
[13, 8, 806],
[13, 9, 839],
[13, 10, 872],
[14, 0, 575],
[14, 1, 609],
[14, 2, 644],
[14, 3, 678],
[14, 4, 713],
[14, 5, 747],
[14, 6, 782],
[14, 7, 816],
[14, 8, 851],
[14, 9, 885],
[14, 10, 920],
[15, 0, 600],
[15, 1, 636],
[15, 2, 672],
[15, 3, 708],
[15, 4, 744],
[15, 5, 780],
[15, 6, 816],
[15, 7, 852],
[15, 8, 888],
[15, 9, 924],
[15, 10, 960],
[16, 0, 620],
[16, 1, 657],
[16, 2, 694],
[16, 3, 731],
[16, 4, 768],
[16, 5, 806],
[16, 6, 843],
[16, 7, 880],
[16, 8, 917],
[16, 9, 954],
[16, 10, 992],
[17, 0, 635],
[17, 1, 673],
[17, 2, 711],
[17, 3, 749],
[17, 4, 787],
[17, 5, 825],
[17, 6, 863],
[17, 7, 901],
[17, 8, 939],
[17, 9, 977],
[17, 10, 1016],
[18, 0, 650],
[18, 1, 689],
[18, 2, 728],
[18, 3, 767],
[18, 4, 806],
[18, 5, 845],
[18, 6, 884],
[18, 7, 923],
[18, 8, 962],
[18, 9, 1001],
[18, 10, 1040],
[19, 0, 665],
[19, 1, 704],
[19, 2, 744],
[19, 3, 784],
[19, 4, 824],
[19, 5, 864],
[19, 6, 904],
[19, 7, 944],
[19, 8, 984],
[19, 9, 1024],
[19, 10, 1064],
];
test("DDR Flare Tests", () => {
function MakeTestCase(internalChartLevel: number, flareLevel: number, flare: number): TestCase {
return () => {
const result = calculate(internalChartLevel, flareLevel);
expect(
result,
`Flare ${flareLevel} on level ${internalChartLevel} chart should be worth ${flare} flare points. Got ${result} instead.`,
).toBe(flare);
};
}
for (const testCase of testCases) {
MakeTestCase(testCase[0], testCase[1], testCase[2])();
}
});
test("DDR Flare Validation Tests", () => {
expectThrowsSnapshot(() => calculate(-1, 0), "Should throw if chart level is negative.");
expectThrowsSnapshot(() => calculate(20, 0), "Should throw if chart level is greater than 19.");
expectThrowsSnapshot(() => calculate(1, -1), "Should throw if flare level is negative.");
expectThrowsSnapshot(() => calculate(1, 11), "Should throw if flare level is greater than 10.");
});
@@ -0,0 +1,28 @@
import { ThrowIf } from "../util/throw-if";
const FLARE_0_POINTS = [
145, 155, 170, 185, 205, 230, 255, 290, 335, 400, 465, 510, 545, 575, 600, 620, 635, 650, 665,
];
/**
* Calculate DDR Flare for a score.
*
* @param internalChartLevel - The internal decimal level of the chart the score was achieved on.
* @param flareLevel - The Flare level chosen to play the song. Goes from 0 ("Flare 0" or no Flare) to 10 ("Flare EX")
*/
export function calculate(internalChartLevel: number, flareLevel: number) {
ThrowIf.negativeOrZero(internalChartLevel, "Internal chart level cannot be negative or zero.", {
level: internalChartLevel,
});
ThrowIf(internalChartLevel > 19, "Internal chart level cannot be greater than 19", {
level: internalChartLevel,
});
ThrowIf.negative(flareLevel, "Flare level cannot be negative.", {
level: flareLevel,
});
ThrowIf(flareLevel > 10, "Flare level cannot be greater than 10.", {
level: flareLevel,
});
return Math.floor((FLARE_0_POINTS[internalChartLevel - 1]! * (100 + flareLevel * 6)) / 100);
}
@@ -0,0 +1,64 @@
import { test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, inverse } from "./gitadora-skill";
test("GITADORA Skill Tests", () => {
function MakeTestCase(scorePercent: number, level: number, expectedSkill: number): TestCase {
return () =>
isAprx(
calculate(scorePercent, level),
expectedSkill,
`A Score Percent of ${scorePercent} on a chart with level ${level} should be worth ${expectedSkill} skill.`,
);
}
const testCases = [
MakeTestCase(89.48, 3.4, 60.84),
MakeTestCase(70.76, 5.8, 82.08),
MakeTestCase(40.2, 9.5, 76.38),
MakeTestCase(97.8, 1, 19.56),
];
for (const testCase of testCases) {
testCase();
}
});
test("GITADORA Skill Validation Tests", () => {
expectThrowsSnapshot(() => calculate(-1, 1), "Should throw if skill provided was negative");
expectThrowsSnapshot(() => calculate(50, -1), "Should throw if level provided was negative");
});
test("GITADORA Inverse Skill Tests", () => {
function MakeTestCase(expectedPercent: number, level: number, skill: number): TestCase {
return () =>
isAprx(
inverse(skill, level),
expectedPercent,
`A Skill Level of ${skill} on a chart with level ${level} should invert to ${expectedPercent} percent.`,
);
}
const testCases = [
MakeTestCase(89.48, 3.4, 60.84),
MakeTestCase(70.76, 5.8, 82.08),
MakeTestCase(40.2, 9.5, 76.38),
];
for (const testCase of testCases) {
testCase();
}
});
test("GITADORA Inverse Skill Validation Tests", () => {
expectThrowsSnapshot(
() => inverse(9_000, 1),
"Should throw if the skill provided was not possible on this chart.",
);
expectThrowsSnapshot(() => inverse(-1, 1), "Should throw if skill provided was negative");
expectThrowsSnapshot(() => inverse(50, -1), "Should throw if level provided was negative");
});
@@ -0,0 +1,41 @@
import { ThrowIf } from "../util/throw-if";
/**
* Calculate a GITADORA Skill value.
*
* @param scorePercent - The percent this score was worth. Must be between 0 and 100.
* @param level - The level for this chart.
*/
export function calculate(scorePercent: number, level: number) {
ThrowIf(scorePercent > 100, "Score Percent cannot be greater than 100.", { scorePercent });
ThrowIf.negative(scorePercent, "Score Percent cannot be negative.", { scorePercent });
ThrowIf.negative(level, "Chart Level cannot be negative.", { level });
const trueRating = scorePercent * level * 20;
return Math.floor(trueRating) / 100;
}
/**
* Given a GITADORA Skill and a level of a chart, return the percent necessary
* to achieve that skill value.
*
* This throws if the skill value is not possible.
*
* @param skill - The skill level to invert.
* @param level - The level of the chart this skill level should be inverted for.
*/
export function inverse(skill: number, level: number) {
ThrowIf.negative(skill, "Skill cannot be negative.", { skill });
ThrowIf.negative(level, "Chart Level cannot be negative.", { level });
const percent = (100 * skill) / (20 * level);
ThrowIf(
percent > 100,
`A skill level of ${skill} is not possible on a chart with level ${level}.`,
{ skill, level },
);
return percent;
}
@@ -0,0 +1,99 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { calculateFromBPMPerMeasure } from "./itg-highest-unbroken";
import { RepeatNTimes } from "../util/misc";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
test("ITG Highest Unbroken Tests", () => {
function MakeTestCase(
expected32: number | null,
bpmPerMeasure: Array<number>,
notesPerMeasure: Array<number>,
diedAt?: number | null,
measures?: number,
): TestCase {
return () => {
const value = calculateFromBPMPerMeasure(
bpmPerMeasure,
notesPerMeasure,
diedAt,
measures,
);
const msg = `A breakdown of ${bpmPerMeasure.join(", ")} should result in a highest ${
measures ?? 32
} of ${expected32}`;
if (expected32 === null || value === null) {
return expect(value, msg).toBe(expected32);
} else {
return isAprx(value, expected32, msg);
}
};
}
const testCases = [
MakeTestCase(null, [], []),
MakeTestCase(190, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3)),
MakeTestCase(
340,
[50, 50, 50, ...RepeatNTimes(340, 16)],
[16, 16, 16, ...RepeatNTimes(32, 16)],
),
MakeTestCase(null, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3), 1),
MakeTestCase(50, [50, 50, 50, ...RepeatNTimes(190, 32)], RepeatNTimes(16, 32 + 3), 32),
MakeTestCase(
200,
RepeatNTimes(200, 21),
[RepeatNTimes(16, 12), RepeatNTimes(32, 6), RepeatNTimes(64, 3)].flat(),
),
MakeTestCase(
null,
[...RepeatNTimes(200, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(200, 31)],
[...RepeatNTimes(16, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(16, 31)],
),
MakeTestCase(
200,
[...RepeatNTimes(200, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(200, 32)],
[...RepeatNTimes(16, 31), ...RepeatNTimes(0, 1), ...RepeatNTimes(16, 32)],
),
MakeTestCase(200, RepeatNTimes(200, 1024), RepeatNTimes(16, 1024), null, 1024),
MakeTestCase(null, RepeatNTimes(200, 1024), RepeatNTimes(16, 1024), null, 1025),
MakeTestCase(200, RepeatNTimes(200, 2), RepeatNTimes(16, 2), null, 2),
MakeTestCase(null, RepeatNTimes(200, 2), RepeatNTimes(16, 2), null, 3),
];
for (const testCase of testCases) {
testCase();
}
expectThrowsSnapshot(
() => calculateFromBPMPerMeasure([], [1]),
"Should throw if bpms and notes don't have same length",
);
expectThrowsSnapshot(
() => calculateFromBPMPerMeasure([], [], -1),
"Should throw if died at is negative",
);
expectThrowsSnapshot(
() => calculateFromBPMPerMeasure([], [], null, 0),
"Should throw if measures is 0",
);
expectThrowsSnapshot(
() => calculateFromBPMPerMeasure([], [], null, 1),
"Should throw if measures is 1",
);
expectThrowsSnapshot(
() => calculateFromBPMPerMeasure([], [], null, -1),
"Should throw if measures is negative",
);
});
@@ -0,0 +1,131 @@
import { ThrowIf } from "../util/throw-if";
/**
* Given an array of notes per measure, NPS per measure and (optionally) when the user died
* calculate the fastest series of unbroken '16ths' at N measures.
*
* 32 @ 190 will return 190,
* 96 @ 190, user died at m40 is converted to 40 @ 190 and will return 190.
*
* 31 @ 190 will return null,
* 32 @ 200 / 32 @ 220 will return 220.
* 31 @ 200 into 1 @ 50 will return 50. (assuming no break between the streams)
*
* @param npsPerMeasure - The notes per second per measure.
* @param notesPerMeasure - The notes per measure.
* @param diedAt - Optionally, when to cut this short, such as if the user died at measure
* 39, and still might've technically did 32 measures of stream.
* @param measures - Optionally, override how many measures need to be unbroken. This must
* be greater than 1. This allows you to calculate Highest 256, or similar.
*
* @returns The BPM of the highest N unbroken measures in this chart.
*/
export function calculateFromNPSPerMeasure(
npsPerMeasure: Array<number>,
notesPerMeasure: Array<number>,
diedAt: number | null = null,
measures = 32,
): number | null {
// Implementation note: Measures of 1 is annoying to handle, and nobody should
// ever want to calculate that because it's stupid. Just gonna not allow it.
ThrowIf(measures <= 1, `Measures must be greater than 1`, { measures });
ThrowIf(
npsPerMeasure.length !== notesPerMeasure.length,
`Notes Per Measure and NPS Per Measure didn't have the same length.`,
{ npsLen: npsPerMeasure.length, notesLen: notesPerMeasure.length },
);
ThrowIf(diedAt !== null && diedAt < 0, `DiedAt must be positive.`, {
diedAt: diedAt ?? "Null? (Impossible)",
});
// Given an array of equivalent bpms per measure, return the fastest bpm
// this user could've effectively streamed N measures of.
let candidates: Array<Candidate> = [];
let highestUnbroken: number | null = null;
const measureData = npsPerMeasure.map((e, i) => [e, notesPerMeasure[i]]);
// If the user died at an earlier measure, only iterate through those measures.
for (let curMeasureIdx = 0; curMeasureIdx < (diedAt ?? measureData.length); curMeasureIdx++) {
const [nps, notes] = measureData[curMeasureIdx];
// if any measure we're in stops doing anything, this is a break.
// lerping over this sucks and is counterintuitive, so we're not going to.
if (notes === 0) {
candidates = [];
}
const unexhaustedCandidates: Array<Candidate> = [];
for (const candidate of candidates) {
candidate.npsWindow.push(nps);
candidate.notesWindow.push(notes);
// If this window contains more than (16 * measures) notes
// it meets the criteria for fastest unbroken of 16ths for N measures.
if (candidate.notesWindow.reduce((a, e) => a + e, 0) >= measures * 16) {
const lowestNPSInWindow = candidate.npsWindow.slice(0).sort((a, b) => a - b)[0];
// if highestUnbroken is null, this is our best shot
if (highestUnbroken === null || lowestNPSInWindow > highestUnbroken) {
highestUnbroken = lowestNPSInWindow;
}
} else {
unexhaustedCandidates.push(candidate);
}
}
// Only keep candidates who's window doesn't already subsume 512 notes.
candidates = unexhaustedCandidates;
candidates.push({
// for debugging
windowStart: curMeasureIdx,
npsWindow: [nps],
notesWindow: [notes],
});
}
if (highestUnbroken === null) {
return null;
}
return npsToBPM(highestUnbroken);
}
interface Candidate {
windowStart: number;
npsWindow: Array<number>;
notesWindow: Array<number>;
}
/**
* Given an array of bpms per measure instead of nps per measure, calculate the fastest
* N measures of unbroken streams played. This is a thin wrapper around the NPS/Measure
* calculator, used for humanising input.
*
* {@see calculateFromNPSPerMeasure}
*/
export function calculateFromBPMPerMeasure(
bpmPerMeasure: Array<number>,
notesPerMeasure: Array<number>,
diedAt?: null | number,
measures?: number,
): number | null {
return calculateFromNPSPerMeasure(
bpmPerMeasure.map(bpmToNPS),
notesPerMeasure,
diedAt,
measures,
);
}
// Always assume 4/4 time. I know it sucks, but stepmania does the same.
function npsToBPM(nps: number) {
return (nps * 60) / 4;
}
function bpmToNPS(bpm: number) {
return (bpm * 4) / 60;
}
@@ -0,0 +1,320 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, inverse } from "./jubility";
const testCases = [
[80, 10.9, 110.1],
[85, 10.9, 116.9],
[90, 10.9, 123.8],
[95, 10.9, 130.7],
[100, 10.9, 137.6],
[105, 10.9, 144.5],
[110, 10.9, 151.3],
[115, 10.9, 158.2],
[120, 10.9, 165.1],
[80, 10.8, 109.0],
[85, 10.8, 115.9],
[90, 10.8, 122.7],
[95, 10.8, 129.5],
[100, 10.8, 136.3],
[105, 10.8, 143.1],
[110, 10.8, 150.0],
[115, 10.8, 156.8],
[120, 10.8, 163.6],
[80, 10.7, 108.0],
[85, 10.7, 114.8],
[90, 10.7, 121.5],
[95, 10.7, 128.3],
[100, 10.7, 135.1],
[105, 10.7, 141.8],
[110, 10.7, 148.6],
[115, 10.7, 155.3],
[120, 10.7, 162.1],
[80, 10.6, 107.0],
[85, 10.6, 113.7],
[90, 10.6, 120.4],
[95, 10.6, 127.1],
[100, 10.6, 133.8],
[105, 10.6, 140.5],
[110, 10.6, 147.2],
[115, 10.6, 153.9],
[120, 10.6, 160.6],
[80, 10.5, 106.0],
[85, 10.5, 112.6],
[90, 10.5, 119.3],
[95, 10.5, 125.9],
[100, 10.5, 132.5],
[105, 10.5, 139.2],
[110, 10.5, 145.8],
[115, 10.5, 152.4],
[120, 10.5, 159.0],
[80, 10.4, 105.0],
[85, 10.4, 111.6],
[90, 10.4, 118.1],
[95, 10.4, 124.7],
[100, 10.4, 131.3],
[105, 10.4, 137.8],
[110, 10.4, 144.4],
[115, 10.4, 151.0],
[120, 10.4, 157.5],
[80, 10.3, 104.0],
[85, 10.3, 110.5],
[90, 10.3, 117.0],
[95, 10.3, 123.5],
[100, 10.3, 130.0],
[105, 10.3, 136.5],
[110, 10.3, 143.0],
[115, 10.3, 149.5],
[120, 10.3, 156.0],
[80, 10.2, 103.0],
[85, 10.2, 109.4],
[90, 10.2, 115.9],
[95, 10.2, 122.3],
[100, 10.2, 128.7],
[105, 10.2, 135.2],
[110, 10.2, 141.6],
[115, 10.2, 148.1],
[120, 10.2, 154.5],
[80, 10.1, 102.0],
[85, 10.1, 108.3],
[90, 10.1, 114.7],
[95, 10.1, 121.1],
[100, 10.1, 127.5],
[105, 10.1, 133.9],
[110, 10.1, 140.2],
[115, 10.1, 146.6],
[120, 10.1, 153.0],
[80, 10.0, 101.0],
[85, 10.0, 107.3],
[90, 10.0, 113.6],
[95, 10.0, 119.9],
[100, 10.0, 126.2],
[105, 10.0, 132.5],
[110, 10.0, 138.8],
[115, 10.0, 145.2],
[120, 10.0, 151.5],
[80, 9.9, 100.0],
[85, 9.9, 106.2],
[90, 9.9, 112.5],
[95, 9.9, 118.7],
[100, 9.9, 125.0],
[105, 9.9, 131.2],
[110, 9.9, 137.5],
[115, 9.9, 143.7],
[120, 9.9, 150.0],
[80, 9.8, 98.9],
[85, 9.8, 105.1],
[90, 9.8, 111.3],
[95, 9.8, 117.5],
[100, 9.8, 123.7],
[105, 9.8, 129.9],
[110, 9.8, 136.1],
[115, 9.8, 142.2],
[120, 9.8, 148.4],
[80, 9.7, 97.9],
[85, 9.7, 104.1],
[90, 9.7, 110.2],
[95, 9.7, 116.3],
[100, 9.7, 122.4],
[105, 9.7, 128.5],
[110, 9.7, 134.7],
[115, 9.7, 140.8],
[120, 9.7, 146.9],
[80, 9.6, 96.9],
[85, 9.6, 103.0],
[90, 9.6, 109.0],
[95, 9.6, 115.1],
[100, 9.6, 121.2],
[105, 9.6, 127.2],
[110, 9.6, 133.3],
[115, 9.6, 139.3],
[120, 9.6, 145.4],
[80, 9.5, 95.9],
[85, 9.5, 101.9],
[90, 9.5, 107.9],
[95, 9.5, 113.9],
[100, 9.5, 119.9],
[105, 9.5, 125.9],
[110, 9.5, 131.9],
[115, 9.5, 137.9],
[120, 9.5, 143.9],
[80, 9.4, 94.9],
[85, 9.4, 100.8],
[90, 9.4, 106.8],
[95, 9.4, 112.7],
[100, 9.4, 118.6],
[105, 9.4, 124.6],
[110, 9.4, 130.5],
[115, 9.4, 136.4],
[120, 9.4, 142.4],
[80, 9.3, 93.9],
[85, 9.3, 99.8],
[90, 9.3, 105.6],
[95, 9.3, 111.5],
[100, 9.3, 117.4],
[105, 9.3, 123.2],
[110, 9.3, 129.1],
[115, 9.3, 135.0],
[120, 9.3, 140.9],
[80, 9.2, 92.9],
[85, 9.2, 98.7],
[90, 9.2, 104.5],
[95, 9.2, 110.3],
[100, 9.2, 116.1],
[105, 9.2, 121.9],
[110, 9.2, 127.7],
[115, 9.2, 133.5],
[120, 9.2, 139.3],
[80, 9.1, 91.9],
[85, 9.1, 97.6],
[90, 9.1, 103.4],
[95, 9.1, 109.1],
[100, 9.1, 114.8],
[105, 9.1, 120.6],
[110, 9.1, 126.3],
[115, 9.1, 132.1],
[120, 9.1, 137.8],
[80, 9.0, 90.9],
[85, 9.0, 96.5],
[90, 9.0, 102.2],
[95, 9.0, 107.9],
[100, 9.0, 113.6],
[105, 9.0, 119.3],
[110, 9.0, 125.0],
[115, 9.0, 130.6],
[120, 9.0, 136.3],
[80, 8, 80.8],
[85, 8, 85.8],
[90, 8, 90.9],
[95, 8, 95.9],
[100, 8, 101.0],
[105, 8, 106.0],
[110, 8, 111.1],
[115, 8, 116.1],
[120, 8, 121.2],
[80, 7, 70.7],
[85, 7, 75.1],
[90, 7, 79.5],
[95, 7, 83.9],
[100, 7, 88.3],
[105, 7, 92.8],
[110, 7, 97.2],
[115, 7, 101.6],
[120, 7, 106.0],
[80, 6, 60.6],
[85, 6, 64.3],
[90, 6, 68.1],
[95, 6, 71.9],
[100, 6, 75.7],
[105, 6, 79.5],
[110, 6, 83.3],
[115, 6, 87.1],
[120, 6, 90.9],
[80, 5, 50.5],
[85, 5, 53.6],
[90, 5, 56.8],
[95, 5, 59.9],
[100, 5, 63.1],
[105, 5, 66.2],
[110, 5, 69.4],
[115, 5, 72.6],
[120, 5, 75.7],
[80, 4, 40.4],
[85, 4, 42.9],
[90, 4, 45.4],
[95, 4, 47.9],
[100, 4, 50.5],
[105, 4, 53.0],
[110, 4, 55.5],
[115, 4, 58.0],
[120, 4, 60.6],
[80, 3, 30.3],
[85, 3, 32.1],
[90, 3, 34.0],
[95, 3, 35.9],
[100, 3, 37.8],
[105, 3, 39.7],
[110, 3, 41.6],
[115, 3, 43.5],
[120, 3, 45.4],
[80, 2, 20.2],
[85, 2, 21.4],
[90, 2, 22.7],
[95, 2, 23.9],
[100, 2, 25.2],
[105, 2, 26.5],
[110, 2, 27.7],
[115, 2, 29.0],
[120, 2, 30.3],
[80, 1, 10.1],
[85, 1, 10.7],
[90, 1, 11.3],
[95, 1, 11.9],
[100, 1, 12.6],
[105, 1, 13.2],
[110, 1, 13.8],
[115, 1, 14.5],
[120, 1, 15.1],
];
test("Jubility Tests", () => {
function MakeTestCase(rate: number, level: number, jubility: number): TestCase {
return () =>
expect(
calculate(700_001, rate, level),
`${rate}% on a level ${level} chart should be worth ${jubility} jubility.`,
).toBe(jubility);
}
for (const testCase of testCases) {
MakeTestCase(testCase[0], testCase[1], testCase[2])();
}
expect(calculate(699_999, 100, 10), "Should return 0 for scores < 700k no matter what.").toBe(
0,
);
});
test("Jubility Validation Tests", () => {
expectThrowsSnapshot(
() => calculate(1_000_001, 100, 10.5),
"Should throw if score is > 1million.",
);
expectThrowsSnapshot(
() => calculate(1_000_000, 120.1, 10.5),
"Should throw if music rate is > 120.",
);
expectThrowsSnapshot(() => calculate(-1, 100, 10.5), "Should throw if score is negative.");
expectThrowsSnapshot(
() => calculate(1_000_000, -1, 10.5),
"Should throw if music rate is negative.",
);
expectThrowsSnapshot(
() => calculate(1_000_000, -1, -1),
"Should throw if chart level is negative.",
);
});
test("Inverse Jubility Tests", () => {
function MakeTestCase(rate: number, level: number, jubility: number): TestCase {
return () =>
isAprx(
Math.ceil(inverse(jubility, level)),
rate,
`Inverse ${jubility} on a level ${level} chart should be equal to ${rate} music rate.`,
1,
);
}
for (const testCase of testCases) {
MakeTestCase(testCase[0], testCase[1], testCase[2])();
}
expectThrowsSnapshot(
() => inverse(9000, 1),
"Should throw if the requested jubility is not possible on a chart of that level.",
);
});
@@ -0,0 +1,50 @@
import { ThrowIf } from "../util/throw-if";
/**
* Calculates the jubility of a score. Returns 0 for anything less than 700k.
*
* @param score - The score value. This is between 0 and 1million.
* @param musicRate - The Music Rate of their score -- this is a percentage value.
* This is between 0 and 120, where 100-120 is only achiveable on hard mode.
* @param level - The level for the chart.
*/
export function calculate(score: number, musicRate: number, level: number) {
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(level, "Level cannot be negative.", { level });
ThrowIf.negative(musicRate, "MusicRate cannot be negative.", { musicRate });
ThrowIf(score > 1_000_000, "Score cannot be greater than 1million.", { score });
ThrowIf(musicRate > 120, "MusicRate cannot be greater than 120.", { musicRate });
if (score < 700_000) {
return 0;
}
const levelInt = Math.round(level * 10);
const musicRateInt = Math.floor(musicRate * 10);
// Note: These constants are arbitrary. I have no idea why they're like this.
const jub = (levelInt * 125 * musicRateInt) / 99;
// Jubility is *always* floored to one decimal place.
return Math.floor(jub / 100) / 10;
}
/**
* Given a jubility, return the musicRate necessary to get that jubility.
* This assumes that the player has a score >= 700k, as otherwise no inversion
* is possible.
*
* @param jubility - The jubility to inversely calculate.
* @param level - The level for the chart.
*/
export function inverse(jubility: number, level: number) {
const rate = (99 / (12.5 * level)) * jubility;
ThrowIf(
rate > 120,
`A jubility of ${jubility} is not possible on a chart with level ${level}.`,
{ jubility, level },
);
return rate;
}
@@ -0,0 +1,82 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, getRank } from "./maimai-rate";
test("maimai Rate Tests", () => {
function MakeTestCase(
score: number,
maxScore: number,
level: number,
expectedRate: number,
): TestCase {
return () =>
isAprx(
calculate(score, maxScore, level),
expectedRate,
`A score of ${score}/${maxScore} on a chart of level ${level} should be worth ${expectedRate} rate.`,
);
}
const testCases = [
MakeTestCase(100.89, 101.58, 11.8, 14.36),
MakeTestCase(100.8, 101.18, 12.0, 14.68),
MakeTestCase(100.23, 100.72, 11.7, 14.02),
MakeTestCase(99.36, 100.42, 11.0, 12.27),
MakeTestCase(98.04, 101.21, 10.2, 10.8),
MakeTestCase(97.62, 100.56, 10.1, 10.47),
MakeTestCase(96.79, 101.19, 10.8, 9.76),
MakeTestCase(95.08, 100.91, 10.5, 9.18),
MakeTestCase(94.16, 100.83, 12.5, 11.04),
MakeTestCase(93.05, 100.31, 11.5, 9.88),
MakeTestCase(92.5, 100.53, 13.6, 11.91),
MakeTestCase(91.62, 100.5, 11.7, 9.9),
MakeTestCase(100.68, 100.68, 14, 19),
MakeTestCase(100, 100.79, 13.9, 17.8),
MakeTestCase(99, 100.71, 12.9, 14.8),
MakeTestCase(99, 100.54, 8, 9.5),
MakeTestCase(99, 100.54, 7, 9),
MakeTestCase(97, 100.36, 11.5, 11.5),
MakeTestCase(97, 100.54, 8, 8),
MakeTestCase(97, 100.54, 7, 7.5),
MakeTestCase(94, 100.3, 10, 8.5),
MakeTestCase(90, 100.3, 12.5, 10.5),
MakeTestCase(80, 100.5, 13, 10),
MakeTestCase(60, 100.59, 12.7, 5.08),
MakeTestCase(40, 100.54, 9, 1.8),
MakeTestCase(20, 100.72, 13.6, 1.36),
MakeTestCase(13, 100.61, 11.7, 0.35),
MakeTestCase(9, 100.26, 10.5, 0),
MakeTestCase(100, 100, 13.9, 18.8),
MakeTestCase(96.99, 100, 8.9, 7.95),
MakeTestCase(100.9, 100.9, 15.4, 21.8),
];
for (const testCase of testCases) {
testCase();
}
});
test("maimai Rate Validation Tests", () => {
expectThrowsSnapshot(
() => calculate(100, 104.1, 10),
"Should throw if max score is greater than 104%.",
);
expectThrowsSnapshot(
() => calculate(101.5, 100.68, 10),
"Should throw if score is greater than max score.",
);
expectThrowsSnapshot(() => calculate(-1, 100.68, 10), "Should throw if score is negative.");
expectThrowsSnapshot(() => calculate(99.5, 100.68, -1), "Should throw if level is negative.");
});
test("getRank falls back to F when no higher rank matches", () => {
expect(getRank(500)).toBe("F");
});
@@ -0,0 +1,130 @@
import { ThrowIf } from "../util/throw-if";
// [F, E, D, C, B, A, AA, AAA, S - 0.01, S, SS - 0.01, SS, SSS - 0.01, SSS, SSS+]
const RATE_TABLE: Record<number, number[]> = {
0: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
10: [0, 0, 10, 20, 40, 100, 150, 250, 400, 400, 500, 550, 650, 700, 800],
20: [0, 0, 20, 40, 80, 200, 250, 300, 450, 450, 550, 600, 700, 750, 850],
30: [0, 0, 30, 60, 120, 300, 350, 400, 500, 500, 600, 650, 750, 800, 900],
40: [0, 0, 40, 80, 160, 350, 400, 450, 550, 550, 650, 700, 800, 850, 950],
50: [0, 0, 50, 100, 200, 400, 500, 550, 600, 600, 700, 750, 850, 900, 1000],
60: [0, 0, 60, 120, 240, 450, 550, 600, 650, 650, 750, 800, 900, 950, 1050],
70: [0, 0, 70, 140, 280, 500, 600, 700, 725, 750, 875, 900, 975, 1000, 1100],
80: [0, 0, 80, 160, 320, 550, 650, 725, 750, 800, 925, 950, 1025, 1050, 1150],
90: [0, 0, 90, 180, 360, 600, 700, 750, 800, 900, 1025, 1050, 1125, 1150, 1250],
100: [0, 0, 100, 200, 400, 700, 800, 850, 900, 1000, 1125, 1150, 1225, 1250, 1350],
110: [0, 0, 110, 220, 440, 800, 900, 950, 1000, 1100, 1175, 1200, 1275, 1300, 1400],
120: [0, 0, 120, 240, 480, 900, 1000, 1050, 1100, 1200, 1275, 1300, 1375, 1400, 1500],
130: [0, 0, 130, 260, 520, 1000, 1100, 1150, 1250, 1350, 1475, 1500, 1575, 1600, 1700],
140: [0, 0, 140, 280, 560, 1100, 1200, 1250, 1400, 1500, 1675, 1700, 1775, 1800, 1900],
150: [0, 0, 150, 300, 600, 1200, 1300, 1350, 1550, 1650, 1875, 1900, 1975, 2000, 2100],
};
const RANK_BOUNDARIES = {
F: 0,
E: 1000,
D: 2000,
C: 4000,
B: 6000,
A: 8000,
AA: 9000,
AAA: 9400,
"S-": 9699,
S: 9700,
"SS-": 9899,
SS: 9900,
"SSS-": 9999,
SSS: 10000,
"SSS+": 20000,
} as const;
export function getRank(score: number): keyof typeof RANK_BOUNDARIES {
const entries = Object.entries(RANK_BOUNDARIES).reverse();
const match = entries.slice(0, -1).find(([_, boundary]) => score >= boundary);
return (match ?? entries[entries.length - 1])[0] as keyof typeof RANK_BOUNDARIES;
}
function calculateRatingCurve(iclInt: number): number[] {
let lowerIcl = Math.floor(iclInt / 10) * 10;
let upperIcl = Math.ceil(iclInt / 10) * 10;
// extrapolate for levels beyond 15
if (iclInt > 150) {
lowerIcl = 140;
upperIcl = 150;
}
if (lowerIcl === upperIcl) {
return RATE_TABLE[lowerIcl];
}
const lowerCurve = RATE_TABLE[lowerIcl];
const upperCurve = RATE_TABLE[upperIcl];
return lowerCurve.map((rateValue, index) => {
const upperRateValue = upperCurve[index];
return (
rateValue + ((upperRateValue - rateValue) * (iclInt - lowerIcl)) / (upperIcl - lowerIcl)
);
});
}
/**
* Calculates maimai rate for a score.
*
* @param score - The percentage score to calculate the rate for.
* @param maxScore - The max percentage score attainable of the chart the score was achieved on.
* @param internalChartLevel - The internal decimal level of the chart the score was achieved on.
*/
export function calculate(score: number, maxScore: number, internalChartLevel: number): number {
// Breaks are the most valuable note type, with a 2500 base score for PERFECTs and up to 100 bonus score.
// Assuming all notes are breaks, the max score is (2600 * n) / (2500 * n) * 100 = 104
ThrowIf(maxScore > 104, "Max score cannot be greater than 104%.", { maxScore });
ThrowIf(score > maxScore, "Score cannot be greater than max score.", { score, maxScore });
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", {
level: internalChartLevel,
});
let scoreInt = Math.round(score * 100);
const maxScoreInt = Math.round(maxScore * 100);
const iclInt = Math.round(internalChartLevel * 10);
if (scoreInt <= 1000) {
return 0;
}
const curve = calculateRatingCurve(iclInt);
if (scoreInt === maxScoreInt) {
return curve[curve.length - 1] / 100;
}
const rank = getRank(scoreInt);
const rankIndex = Object.keys(RANK_BOUNDARIES).indexOf(rank);
// For whatever reason, rating between SSS and SSS+ is calculated by scaling
// the score between 100% and 200%, with the base score taking up 100% and
// the break score being another 100%.
if (rank === "SSS") {
scoreInt = Math.floor(((scoreInt - 10000) / (maxScoreInt - 10000)) * 10000 + 10000);
}
const lowerRateValue = curve[rankIndex];
const lowerScoreBoundary = RANK_BOUNDARIES[rank];
// Fast path: Return the rate value as-is if the score is right on the border.
if (scoreInt === lowerScoreBoundary) {
return lowerRateValue / 100;
}
const upperRateValue = curve[rankIndex + 1];
const upperScoreBoundary = Object.values(RANK_BOUNDARIES)[rankIndex + 1];
const rate =
lowerRateValue +
(upperRateValue - lowerRateValue) *
((scoreInt - lowerScoreBoundary) / (upperScoreBoundary - lowerScoreBoundary));
return Math.floor(rate) / 100;
}
@@ -0,0 +1,97 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, MaimaiDXLamps } from "./maimaidx-rate";
test("maimai DX Rate Tests", () => {
function MakeTestCase(
score: number,
level: number,
lamp: MaimaiDXLamps | undefined,
expectedRate: number,
): TestCase {
return () =>
isAprx(
calculate(score, level, lamp),
expectedRate,
`A score of ${score} on a chart of level ${level} with lamp ${lamp} should be worth ${expectedRate} rate.`,
);
}
const testCases = [
MakeTestCase(100.1398, 13, "CLEAR", 281),
MakeTestCase(100.1379, 12.7, "CLEAR", 274),
MakeTestCase(100.227, 12.5, "CLEAR", 270),
MakeTestCase(99.4936, 12.8, "CLEAR", 264),
MakeTestCase(101, 13, "ALL PERFECT+", 293),
MakeTestCase(100.5, 13, "ALL PERFECT", 293),
MakeTestCase(100.5, 13, "CLEAR", 292),
MakeTestCase(100.4999, 14, "CLEAR", 312),
MakeTestCase(100, 13, "ALL PERFECT", 281),
MakeTestCase(100, 13, "CLEAR", 280),
MakeTestCase(99.9999, 13.7, "CLEAR", 293),
MakeTestCase(99.5, 13.7, "CLEAR", 287),
MakeTestCase(99, 12.7, "CLEAR", 261),
MakeTestCase(98.9999, 12.9, "CLEAR", 263),
MakeTestCase(98, 10, "CLEAR", 198),
MakeTestCase(97, 8, "CLEAR", 155),
MakeTestCase(96.9999, 10.7, "CLEAR", 182),
MakeTestCase(94, 10.7, "CLEAR", 168),
MakeTestCase(90, 5, "CLEAR", 68),
MakeTestCase(80, 11.5, "CLEAR", 125),
MakeTestCase(79.9999, 11.5, "FAILED", 117),
MakeTestCase(75, 12.4, "FAILED", 111),
MakeTestCase(70, 14.2, "FAILED", 111),
MakeTestCase(60, 15, "FAILED", 86),
MakeTestCase(50, 12.6, "FAILED", 50),
MakeTestCase(40, 12.6, "FAILED", 32),
MakeTestCase(30, 12.6, "FAILED", 18),
MakeTestCase(20, 12.6, "FAILED", 8),
MakeTestCase(10, 12.6, "FAILED", 2),
MakeTestCase(0, 12.6, "FAILED", 0),
MakeTestCase(101, 13, undefined, 292),
MakeTestCase(100.5, 13, undefined, 292),
MakeTestCase(75, 12.4, undefined, 111),
];
for (const testCase of testCases) {
testCase();
}
expect(
calculate(100.5, 13, "ALL PERFECT"),
"101% and 100.5% should give identical rate on ALL PERFECT.",
).toBe(calculate(101, 13, "ALL PERFECT+"));
});
test("maimai DX Rate Validation Tests", () => {
expectThrowsSnapshot(() => calculate(-1, 10, "FAILED"), "Should throw if score is negative.");
expectThrowsSnapshot(
() => calculate(101.5, 10, "ALL PERFECT+"),
"Should throw if score is greater than 101%.",
);
expectThrowsSnapshot(() => calculate(99.5, -1, "CLEAR"), "Should throw if level is negative.");
expectThrowsSnapshot(
() => calculate(100.5, 10, "ALL PERFECT+"),
"Should throw if lamp is ALL PERFECT+ but score is not 101%.",
);
expectThrowsSnapshot(
() => calculate(101, 10, "ALL PERFECT"),
"Should throw if score is 101% but lamp is not ALL PERFECT+",
);
expectThrowsSnapshot(
() => calculate(99.9, 10, "ALL PERFECT"),
"Should throw if lamp is ALL PERFECT but score is below 100%.",
);
expectThrowsSnapshot(
() => calculate(79, 10, "CLEAR"),
"Should throw if lamp is CLEAR but score is below 80%.",
);
expectThrowsSnapshot(
() => calculate(81, 10, "FAILED"),
"Should throw if lamp is FAILED but score is above 80%.",
);
});
@@ -0,0 +1,98 @@
import { ThrowIf } from "../util/throw-if";
export type MaimaiDXLamps =
| "FAILED"
| "CLEAR"
| "FULL COMBO"
| "FULL COMBO+"
| "ALL PERFECT"
| "ALL PERFECT+";
const RATING_COEFFICIENTS = new Map([
[100_5000, 224],
[100_4999, 222],
[100_0000, 216],
[99_9999, 214],
[99_5000, 211],
[99_0000, 208],
[98_9999, 206],
[98_0000, 203],
[97_0000, 200],
[96_9999, 176],
[94_0000, 168],
[90_0000, 152],
[80_0000, 136],
[79_9999, 128],
[75_0000, 120],
[70_0000, 112],
[60_0000, 96],
[50_0000, 80],
[40_0000, 64],
[30_0000, 48],
[20_0000, 32],
[10_0000, 16],
[0, 0],
]);
/**
* Calculate maimai DX Splash+ (and newer) rate for a score.
*
* @param score - The score to calculate the rate for.
* @param internalChartLevel - The internal decimal level of the chart the score was achieved on.
* @param lamp - The lamp for this score. Since maimai DX CiRCLE, there is a 1 rating bonus
* for ALL PERFECT/ALL PERFECT+ scores.
*/
export function calculate(score: number, internalChartLevel: number, lamp?: MaimaiDXLamps) {
ThrowIf(score > 101, "Score cannot be greater than 101%.", { score });
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", {
level: internalChartLevel,
});
ThrowIf(
lamp === "ALL PERFECT+" && score !== 101,
"Cannot have an ALL PERFECT+ without 101%.",
// @ts-expect-error Lamp is "ALL PERFECT+" if the exception is thrown.
{ score, lamp },
);
ThrowIf(
score === 101 && lamp !== undefined && lamp !== "ALL PERFECT+",
"A score of 101% should be an ALL PERFECT+.",
// @ts-expect-error Lamp is defined if the exception is thrown.
{ score, lamp },
);
ThrowIf(
lamp === "ALL PERFECT" && score < 100,
"Cannot have an ALL PERFECT without at least 100%.",
// @ts-expect-error Lamp is "ALL PERFECT" if the exception is thrown.
{ score, lamp },
);
ThrowIf(
score >= 80 && lamp === "FAILED",
"A score of >=80% should not be a FAILED.",
// @ts-expect-error Lamp is "FAILED" if the exception is thrown.
{ score, lamp },
);
ThrowIf(
score < 80 && lamp === "CLEAR", // check specifically CLEARs so that sub-80% FCs are still permitted
"A score of <80% should not be a CLEAR.",
// @ts-expect-error Lamp is "CLEAR" if the exception is thrown.
{ score, lamp },
);
// Scores above 100.5% are capped at 100.5% by the algorithm.
const scoreInt = Math.min(Math.round(score * 10000), 100_5000);
const iclInt = Math.round(internalChartLevel * 10);
const bonus = lamp === "ALL PERFECT" || lamp === "ALL PERFECT+" ? 1 : 0;
const coeffs = [...RATING_COEFFICIENTS];
// Last entry is boundary 0. Omitting it from the loop avoids an impossible
// branch (scoreInt < 0) on the final `scoreInt >= scoreBoundary` check.
for (let i = 0; i < coeffs.length - 1; i++) {
const [scoreBoundary, coefficient] = coeffs[i];
if (scoreInt >= scoreBoundary) {
return Math.floor((scoreInt * coefficient * iclInt) / 100_000_000) + bonus;
}
}
const [, coefficient] = coeffs[coeffs.length - 1];
return Math.floor((scoreInt * coefficient * iclInt) / 100_000_000) + bonus;
}
@@ -0,0 +1,169 @@
import { expect, test } from "vitest";
import fs from "node:fs";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, calculatePlatinum, calculateRefresh, OngekiNoteLamp } from "./ongeki-rating";
interface TestData {
ratingRefresh: {
internalChartLevel: number;
technicalScore: number;
noteLamp: OngekiNoteLamp;
fullBell: boolean;
expectedRating: number;
}[];
ratingPlatinum: {
internalChartLevel: number;
stars: number;
expectedRating: number;
}[];
}
test("O.N.G.E.K.I. Classic Rating Tests", () => {
const LEVEL = 12.5;
expect(calculate(1_010_000, LEVEL)).toBe(LEVEL + 2);
expect(calculate(1_007_500, LEVEL)).toBe(LEVEL + 2);
expect(calculate(1_000_000, LEVEL)).toBe(LEVEL + 1.5);
expect(calculate(990_000, LEVEL)).toBe(LEVEL + 1);
expect(calculate(970_000, LEVEL)).toBe(LEVEL);
expect(calculate(900_000, LEVEL)).toBe(LEVEL - 4);
expect(calculate(800_000, LEVEL)).toBe(LEVEL - 6);
expect(calculate(500_000, LEVEL)).toBe(0);
expect(calculate(0, LEVEL)).toBe(0);
expect(calculate(987_000, LEVEL)).toBe(13.35);
expect(calculate(1_003_000, LEVEL)).toBe(14.2);
expect(calculate(999_000, LEVEL)).toBe(13.95);
expect(calculate(994_000, LEVEL)).toBe(13.7);
expect(calculate(980_000, LEVEL)).toBe(13);
expect(calculate(950_000, LEVEL)).toBe(11.35);
expect(calculate(600_000, LEVEL)).toBe(0);
expect(calculate(50_000, LEVEL)).toBe(0);
});
test("O.N.G.E.K.I. Classic Rating Edge Cases", () => {
expect(
calculate(1_010_000, 0),
"A perfect score on a chart with level 0 should be worth 0.",
).toBe(0);
expect(calculate(0, 12.5), "A score of 0 should be worth 0.").toBe(0);
expect(calculate(0, 0), "A score of 0 on a chart with level 0 should be worth 0.").toBe(0);
expect(calculate(1_007_880, 14.4), "An SSS+ on a 14.4 should be worth 16.4.").toBe(16.4);
});
test("O.N.G.E.K.I. Refresh Rating Tests", () => {
const LEVEL = 12.5;
expect(calculateRefresh(LEVEL, 1_010_000, "ALL BREAK+", true)).toBe(LEVEL + 2.7);
expect(calculateRefresh(LEVEL, 970_000, "CLEAR", false)).toBe(LEVEL);
expect(calculateRefresh(LEVEL, 970_000, "FULL COMBO", true)).toBe(LEVEL + 0.15);
expect(calculateRefresh(LEVEL, 952_510, "LOSS", false)).toBe(LEVEL - 1);
expect(calculateRefresh(LEVEL, 952_500, "LOSS", false)).toBe(LEVEL - 1);
expect(calculateRefresh(LEVEL, 952_490, "LOSS", false)).toBe(LEVEL - 1.001);
expect(calculateRefresh(LEVEL, 900_000, "LOSS", false)).toBe(LEVEL - 4);
expect(calculateRefresh(LEVEL, 850_000, "LOSS", false)).toBe(LEVEL - 5);
expect(calculateRefresh(LEVEL, 800_000, "CLEAR", true)).toBe(LEVEL - 6 + 0.05);
});
test("O.N.G.E.K.I. Refresh Rating Edge Cases", () => {
expect(
calculateRefresh(0, 1_010_000, "ALL BREAK+", true),
"A perfect score on a chart with level 0 should be worth 0.",
).toBe(0);
expect(calculateRefresh(12.5, 0, "LOSS", false), "A score of 0 should be worth 0.").toBe(0);
expect(
calculateRefresh(0, 0, "LOSS", false),
"A score of 0 on a chart with level 0 should be worth 0.",
).toBe(0);
});
test("O.N.G.E.K.I. Refresh Rating Real-world Tests", () => {
const testData: TestData = JSON.parse(
fs.readFileSync("test-data/ongeki-rating.json").toString(),
);
for (const d of testData.ratingRefresh) {
expect(
calculateRefresh(d.internalChartLevel, d.technicalScore, d.noteLamp, d.fullBell),
`${d.technicalScore} on a ${d.internalChartLevel} should be equal ${d.expectedRating}`,
).toBe(d.expectedRating);
}
});
test("O.N.G.E.K.I. Platinum Rating Real-world Tests", () => {
const testData: TestData = JSON.parse(
fs.readFileSync("test-data/ongeki-rating.json").toString(),
);
for (const d of testData.ratingPlatinum) {
expect(
calculatePlatinum(d.internalChartLevel, d.stars),
`${d.stars} on a ${d.internalChartLevel} should be equal ${d.expectedRating}`,
).toBe(d.expectedRating);
}
expect(calculatePlatinum(15.7, 0), "0 stars should always equal 0 rating").toBe(0);
});
test("O.N.G.E.K.I. Rating Validation Tests", () => {
expectThrowsSnapshot(
() => calculate(-1, 12.5),
"Classic should throw if your score is negative.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, -1, "LOSS", false),
"Refresh should throw if your score is negative.",
);
expectThrowsSnapshot(
() => calculate(1_010_001, 12.5),
"Classic should throw if your score is >= 1.01million.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, 1_010_001, "CLEAR", true),
"Refresh should throw if your score is >= 1.01million.",
);
expectThrowsSnapshot(
() => calculate(900_000, -1),
"Classic should throw if chart level is negative.",
);
expectThrowsSnapshot(
() => calculateRefresh(-1, 900_000, "CLEAR", false),
"Refresh should throw if chart level is negative.",
);
expectThrowsSnapshot(
() => calculatePlatinum(-1, 5),
"Platinum should throw if chart level is negative.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, 900_000, "ALL BREAK+", true),
"Refresh should throw if lamp is ALL BREAK+ without 1.01M.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, 1_010_000, "ALL BREAK", true),
"Refresh should throw if your score is 1.01M without ALL BREAK+.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, 1_010_000, "ALL BREAK+", false),
"Refresh should throw if your score is 1.01M without FULL BELL.",
);
expectThrowsSnapshot(
() => calculateRefresh(12.5, 1_000_000, "LOSS", true),
"Refresh should throw if your score is a LOSS FULL BELL.",
);
expectThrowsSnapshot(
() => calculatePlatinum(12.5, -1),
"Platinum should throw if star number is negative.",
);
expectThrowsSnapshot(() => calculatePlatinum(12.5, 7), "Platinum should throw if stars > 6.");
});
@@ -0,0 +1,147 @@
import { ThrowIf } from "../util/throw-if";
export type OngekiNoteLamp = "LOSS" | "CLEAR" | "FULL COMBO" | "ALL BREAK" | "ALL BREAK+";
/**
* Calculates the rating for an O.N.G.E.K.I. score.
* This is accurate up to bright MEMORY Act.3.
*
* @param technicalScore - The technical score the user got. This is a value between 0 and 1.01million.
* @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally.
*/
export function calculate(technicalScore: number, internalChartLevel: number) {
ThrowIf(technicalScore > 1_010_000, "Technical score cannot be greater than 1.01Million.", {
technicalScore,
});
ThrowIf.negative(technicalScore, "Technical score cannot be negative.", {
technicalScore,
});
ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", {
internalChartLevel,
});
if (internalChartLevel === 0) {
return 0;
}
let ratingValue = 0;
const iclInt = Math.round(internalChartLevel * 100.0);
if (technicalScore >= 1_007_500) {
ratingValue = iclInt + 200;
} else if (technicalScore >= 1_000_000) {
ratingValue = iclInt + 150 + Math.floor((technicalScore - 1_000_000) / 150);
} else if (technicalScore >= 970_000) {
ratingValue = iclInt + Math.floor((technicalScore - 970_000) / 200);
} else if (technicalScore >= 900000) {
ratingValue = iclInt - Math.ceil((970_000 - technicalScore) / 175);
} else if (technicalScore >= 800000) {
ratingValue = iclInt - 400 - Math.ceil((900_000 - technicalScore) / 500);
}
return Math.max(ratingValue / 100.0, 0);
}
/**
* Calculates the rating for an O.N.G.E.K.I. score.
* This is accurate as of Re:Fresh.
*
* @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally.
* @param technicalScore - The technical score the user got. This is a value between 0 and 1.01million.
* @param noteLamp - The note lamp
* @param fullBell - Whether the bell lamp is "FULL BELL"
*/
export function calculateRefresh(
internalChartLevel: number,
technicalScore: number,
noteLamp: OngekiNoteLamp,
fullBell: boolean,
) {
ThrowIf(technicalScore > 1_010_000, "Technical score cannot be greater than 1.01Million.", {
technicalScore,
});
ThrowIf.negative(technicalScore, "Technical score cannot be negative.", {
technicalScore,
});
ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", {
internalChartLevel,
});
ThrowIf(
technicalScore === 1_010_000 && (!fullBell || noteLamp !== "ALL BREAK+"),
"Invalid AB+",
{ fullBell: `${fullBell}`, noteLamp },
);
ThrowIf(technicalScore < 1_010_000 && noteLamp === "ALL BREAK+", "Invalid AB+", {
fullBell: `${fullBell}`,
noteLamp,
});
ThrowIf(noteLamp === "LOSS" && fullBell, "Cannot have a LOSS FULL BELL", { noteLamp });
if (internalChartLevel === 0) {
return 0;
}
const iclInt = Math.round(internalChartLevel * 1000.0);
let ratingValue = iclInt;
if (technicalScore >= 1_007_500) {
ratingValue += 1750 + Math.trunc((250 * (technicalScore - 1_007_500)) / 2500);
// SSS+ bonus
ratingValue = ratingValue + 300;
} else if (technicalScore >= 1_000_000) {
ratingValue += 1250 + Math.trunc((500 * (technicalScore - 1_000_000)) / 7500);
// SSS bonus
ratingValue = ratingValue + 200;
} else if (technicalScore >= 990_000) {
ratingValue += 750 + Math.trunc((500 * (technicalScore - 990_000)) / 10000);
// SS bonus
ratingValue = ratingValue + 100;
} else if (technicalScore >= 970_000) {
ratingValue += Math.floor((750 * (technicalScore - 970_000)) / 20000);
} else if (technicalScore >= 900_000) {
ratingValue -= Math.ceil((4000 * (970_000 - technicalScore)) / 70000);
} else if (technicalScore >= 800_000) {
ratingValue -= 4000 + Math.ceil((2000 * (900_000 - technicalScore)) / 100000);
} else {
return 0;
}
if (fullBell) {
ratingValue += 50;
}
if (noteLamp === "FULL COMBO") {
ratingValue += 100;
} else if (noteLamp === "ALL BREAK") {
ratingValue += 300;
} else if (noteLamp === "ALL BREAK+") {
ratingValue += 350;
}
return Math.max(ratingValue / 1000.0, 0);
}
/**
* Calculates the star rating for an O.N.G.E.K.I. score.
* This is accurate as of Re:Fresh.
*
* @param internalChartLevel - The internal chart level. This is a decimal value stored by the game internally.
* @param stars - The number of stars, 0-6 with 6 being "rainbow 5-stars"
*/
export function calculatePlatinum(internalChartLevel: number, stars: number) {
ThrowIf.negative(internalChartLevel, "Chart level cannot be negative.", {
internalChartLevel,
});
ThrowIf(stars < 0 || stars > 6, "Invalid number of stars", { stars });
// Rainbow 5-stars have the same rating value as regular 5-stars
if (stars === 6) {
stars = 5;
}
return Math.floor(stars * internalChartLevel * internalChartLevel) / 1000.0;
}
@@ -0,0 +1,100 @@
import { test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { expectThrowsSnapshot } from "../test-utils/throw-snapshot";
import { calculate, inverse, PopnLamps } from "./popn-classpoints";
test("Pop'n Class Points Tests", () => {
function MakeTestCase(
score: number,
lamp: PopnLamps,
level: number,
expectedClassPoints: number,
): TestCase {
return () =>
isAprx(
calculate(score, lamp, level),
expectedClassPoints,
`A score of ${score} and lamp ${lamp} on a chart with level ${level} should be worth ${expectedClassPoints} class points.`,
);
}
const testCases = [
MakeTestCase(91_134, "CLEAR", 49, 98.19),
MakeTestCase(91_134, "EASY CLEAR", 49, 98.19),
MakeTestCase(91_134, "FULL COMBO", 49, 98.55),
MakeTestCase(91_134, "FAILED", 49, 97.63),
MakeTestCase(89_028, "CLEAR", 49, 97.79),
MakeTestCase(90_480, "FAILED", 49, 97.51),
MakeTestCase(88_259, "CLEAR", 48, 95.82),
MakeTestCase(0, "CLEAR", 48, 0),
MakeTestCase(49_999, "CLEAR", 48, 0),
MakeTestCase(100_000, "PERFECT", 1, 11.94),
MakeTestCase(100_000, "PERFECT", 50, 102.02),
MakeTestCase(100_000, "FULL COMBO", 50, 102.02),
];
for (const testCase of testCases) {
testCase();
}
});
test("Pop'n Class Points Validation Tests", () => {
expectThrowsSnapshot(
() => calculate(95_000, "CLEAR", -1),
"Should throw if chart level is negative.",
);
expectThrowsSnapshot(() => calculate(100_001, "CLEAR", 10), "Should throw if score is > 100k.");
expectThrowsSnapshot(() => calculate(-1, "CLEAR", 10), "Should throw if score is negative.");
expectThrowsSnapshot(
() => calculate(72_000, "FOO" as PopnLamps, 10),
"Should throw if lamp is invalid.",
);
});
test("Pop'n Inverse Class Points Tests", () => {
function MakeTestCase(
expectedScore: number,
lamp: PopnLamps,
level: number,
classPoints: number,
): TestCase {
return () =>
isAprx(
inverse(classPoints, lamp, level),
expectedScore,
`${classPoints} class points and lamp ${lamp} on a chart with level ${level} should invert to ${expectedScore} score.`,
);
}
const testCases = [
MakeTestCase(91_154, "CLEAR", 49, 98.19),
MakeTestCase(91_154, "EASY CLEAR", 49, 98.19),
MakeTestCase(91_112, "FULL COMBO", 49, 98.55),
MakeTestCase(91_107, "FAILED", 49, 97.63),
MakeTestCase(88_978, "CLEAR", 49, 97.79),
MakeTestCase(90_454, "FAILED", 49, 97.51),
MakeTestCase(88_261, "CLEAR", 48, 95.82),
MakeTestCase(0, "CLEAR", 48, 0),
MakeTestCase(99_954, "PERFECT", 1, 11.94),
MakeTestCase(99_989, "PERFECT", 50, 102.02),
MakeTestCase(99_989, "FULL COMBO", 50, 102.02),
];
for (const testCase of testCases) {
testCase();
}
});
test("Pop'n Inverse Class Points Validation Tests", () => {
expectThrowsSnapshot(
() => inverse(9000, "FAILED", 1),
"Should throw if the provided class points are impossible to achieve given the other constraints.",
);
expectThrowsSnapshot(
() => inverse(10, "CLEAR", -1),
"Should throw if chart level is negative.",
);
});
@@ -0,0 +1,70 @@
import { ThrowIf } from "../util/throw-if";
import { integer } from "../util/types";
export type PopnLamps = "FAILED" | "EASY CLEAR" | "CLEAR" | "FULL COMBO" | "PERFECT";
/**
* Calculate the pop'n class points for an individual score.
*
* @param score - The score value. Between 0 and 100k.
* @param lamp - The lamp for this score.
* @param level - The level for this chart. Typically between 1 and 50,
* but the upper bound is not enforced here.
*/
export function calculate(score: integer, lamp: PopnLamps, level: integer) {
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf(score > 100_000, "Score cannot be better than 100k.", { score });
ThrowIf.negative(level, "Chart level cannot be negative.", { level });
if (score <= 50000) {
return 0;
}
const clearBonus = GetClearBonus(lamp);
return (10_000 * level + score - 50_000 + clearBonus) / 5440;
}
function GetClearBonus(lamp: PopnLamps) {
if (lamp === "CLEAR" || lamp === "EASY CLEAR") {
return 3000;
} else if (lamp === "FULL COMBO" || lamp === "PERFECT") {
return 5000;
} else if (lamp === "FAILED") {
return 0;
}
throw new Error(
`Unknown lamp of ${lamp} passed to pop'n Class Points calculations. Expected any of FAILED, EASY CLEAR, CLEAR, FULL COMBO, PERFECT.`,
);
}
/**
* Given a pop'n class points value, expected lamp and chart level, return the
* score necessary to get that amount of class points.
*
* @param classPoints - The class points to invert.
* @param lamp - The lamp to invert for. The lamp affects how much score is needed
* to achieve a given class points value.
* @param level - The level for the chart. Typically between 1 and 50,
* but the upper bound is not enforced here.
*/
export function inverse(classPoints: number, lamp: PopnLamps, level: integer) {
ThrowIf.negative(level, "Chart level cannot be negative.", { level });
if (classPoints === 0) {
return 0;
}
const clearBonus = GetClearBonus(lamp);
const expectedScore = 5440 * classPoints - 10_000 * level + 50_000 - clearBonus;
ThrowIf(
expectedScore > 100_000,
`${classPoints} class points is not achievable on a chart with level ${level} and lamp ${lamp}.`,
{ classPoints, level, lamp },
);
return Math.round(expectedScore);
}
@@ -0,0 +1,38 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { calculate } from "./potential";
test("Arcaea Potential Tests", () => {
function MakeTestCase(score: number, level: number, expectedPotential: number): TestCase {
return () =>
isAprx(
calculate(score, level),
expectedPotential,
`A score of ${score} on a chart of level ${level} should be worth ${expectedPotential}`,
);
}
const testCases = [
MakeTestCase(9_977_755, 11.3, 13.19),
MakeTestCase(9_934_498, 11.1, 12.77),
MakeTestCase(9_932_746, 10.9, 12.56),
MakeTestCase(10_000_000, 7.0, 9.0),
MakeTestCase(9_900_000, 8.0, 9.5),
MakeTestCase(9_800_000, 9.5, 10.5),
MakeTestCase(9_500_000, 8.9, 8.9),
MakeTestCase(9_200_000, 7.5, 6.5),
MakeTestCase(8_900_000, 9.5, 7.5),
MakeTestCase(8_600_000, 10.5, 7.5),
];
for (const testCase of testCases) {
testCase();
}
expect(
calculate(10_000_000, 10.0),
"Anything above 10,000,000 should give identical potential.",
).toBe(calculate(10_001_000, 10.0));
});

Some files were not shown because too many files have changed in this diff Show More