mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-03 20:38:17 +03:00
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:
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
@@ -20,4 +20,4 @@ server:
|
||||
bun --watch src/main.ts
|
||||
|
||||
client:
|
||||
cd typescript/client && vite
|
||||
cd typescript/client && bun vite
|
||||
@@ -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}}"
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Metrics are ingested via remote write from Grafana Alloy (see alloy-config.alloy).
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs: []
|
||||
Executable
+157
@@ -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."
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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=""
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"bench": "vitest bench --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"kysely": "catalog:",
|
||||
"pg": "catalog:"
|
||||
"kysely": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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
Reference in New Issue
Block a user