Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
79ffe7f
feat: add predgg coach data foundation
Aug 26, 2026
916e171
fix: bound catalog backfill to recent patches
Aug 26, 2026
a0e5173
fix: resolve build catalogs for internal game versions
Aug 26, 2026
630ec74
fix: backfill new telemetry during player enrichment
Aug 26, 2026
499c4ff
fix: align rating distribution with live schema
Aug 26, 2026
277a57e
feat: expose build coach from personal match detail
Aug 26, 2026
20e37ae
fix: distinguish final items from ancestors
Aug 26, 2026
685218e
feat: surface builds and ancestors in player coach
Aug 26, 2026
3510e93
fix: label Pred.gg ancestor slots
Aug 26, 2026
aaf5b35
fix: open synced matches without Pred.gg live access
Aug 26, 2026
b7b7afd
feat: explain contextual build coaching
Aug 26, 2026
de3e3ea
fix: keep build advice role-aware and non-duplicative
Aug 26, 2026
e2539ea
test: cover role-aware build advice
Aug 26, 2026
18224a8
feat: teach build patterns in weekly coach report
Aug 26, 2026
2789a9a
feat: make match build coaching educational
Aug 26, 2026
2dfb162
fix: rank build evidence by match impact
Aug 26, 2026
744c4a6
fix: compare loadouts against match perk catalog
Aug 26, 2026
fcfee90
feat: analyze full build playstyle and tradeoffs
Aug 26, 2026
f5c1e05
fix: remove noisy global build classifications
Aug 26, 2026
a0d06d3
fix: explain unavailable gold timeline
Aug 26, 2026
1eea0aa
fix: preserve gold timeline on match resync
Aug 26, 2026
e678ff0
fix: retry public event stream after api key rejection
Aug 26, 2026
0f8e5b7
fix: sync public gold timeline separately
Aug 26, 2026
4d3d7fe
fix: backfill missing gold timelines in player sync
Aug 26, 2026
29c0848
feat: build educational solo queue coach
saggacce Aug 27, 2026
a1b9826
feat: add guided replay learning moments
saggacce Aug 27, 2026
9f99030
feat: complete player coach learning workflow
saggacce Aug 27, 2026
3e14263
feat: add traceable coach knowledge foundation
saggacce Aug 27, 2026
44ec138
feat: add adaptive player academy
saggacce Aug 27, 2026
2786c51
fix: keep dropdown options readable in dark theme
saggacce Aug 27, 2026
916d3ad
feat: start player assessment on first visit
saggacce Aug 27, 2026
141452d
feat: make player placement assessment diagnostic
saggacce Aug 27, 2026
be38070
feat: expand player knowledge placement
saggacce Aug 27, 2026
51d0c98
feat: add adaptive player progression and live coach safeguards
saggacce Aug 27, 2026
2c4578d
feat: add secure Windows companion shell
saggacce Aug 27, 2026
b50f5a2
feat: add local mode OCR and live session lifecycle
saggacce Aug 27, 2026
aa5f549
feat: add auditable live training reports
saggacce Aug 27, 2026
348c10b
fix: default private companion to local staging
saggacce Aug 27, 2026
7f6ed5b
feat: add safe visual mode calibration
saggacce Aug 27, 2026
b69e231
feat: record conservative live hud observations
saggacce Aug 27, 2026
33e4750
feat: turn live captures into guided replay reviews
saggacce Aug 27, 2026
eac9808
feat: expose live detector readiness
saggacce Aug 27, 2026
b08c8d4
feat: label overlay detector accuracy
saggacce Aug 27, 2026
f6d9502
feat: measure missed overlay events
saggacce Aug 27, 2026
e50c64a
fix: unblock companion mode calibration
saggacce Aug 27, 2026
2c7f03a
fix: score OCR confidence on matched mode label
saggacce Aug 27, 2026
9e0bdb0
feat: localize companion mode detection
saggacce Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ PRED_GG_TOKEN_URL_FALLBACK=https://pred.saibotu.de/api/oauth2/token
PRED_GG_OAUTH_SCOPES=offline_access profile player:read:interval hero_leaderboard:read matchup_statistic:read
PRED_GG_CLIENT_AUTH_METHOD=auto
PRED_GG_SEND_CLIENT_SECRET=false
# Prevent one slow upstream request from blocking a complete history sync.
PREDGG_REQUEST_TIMEOUT_MS=30000

# ── PostgreSQL ────────────────────────────────────────────────────────────────
DATABASE_URL="postgresql://predecessor:predecessor@localhost:5432/predecessor_build"
Expand All @@ -35,5 +37,10 @@ FRONTEND_URL=http://localhost:5173
VITE_API_URL=http://localhost:3001

# Data retention window in months (default: 3)
# Older matches, event stream, and inactive players are deleted by the monthly cron.
# Only unowned matches are deleted by the monthly cron. Matches involving a
# linked account or a team roster player are preserved for longitudinal coaching.
DATA_RETENTION_MONTHS=3

# Personal history import window in months (default: 60).
# This is intentionally independent from global telemetry retention.
PERSONAL_HISTORY_MONTHS=60
59 changes: 59 additions & 0 deletions .github/workflows/companion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Windows Companion

on:
workflow_dispatch:
pull_request:
branches: [main, develop]
paths:
- 'apps/companion/**'
- 'apps/web/src/pages/PlayerAcademyPage.tsx'
- 'apps/web/src/types/companion.d.ts'
- 'apps/web/src/services/liveModeOcr.ts'
- 'apps/web/src/services/liveHudObservation.ts'
- 'apps/web/src/services/modeTemplateDetector.ts'
- 'apps/api/src/routes/player-learning.ts'
- 'package-lock.json'
- '.github/workflows/companion.yml'
push:
branches: [main]
paths:
- 'apps/companion/**'
- 'apps/web/src/pages/PlayerAcademyPage.tsx'
- 'apps/web/src/types/companion.d.ts'
- 'apps/web/src/services/liveModeOcr.ts'
- 'apps/web/src/services/liveHudObservation.ts'
- 'apps/web/src/services/modeTemplateDetector.ts'
- 'apps/api/src/routes/player-learning.ts'
- 'package-lock.json'
- '.github/workflows/companion.yml'

jobs:
package-windows:
name: Build Windows installer
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Type-check and build companion
run: npm --workspace @predecessor/companion run build

- name: Package Windows installer
run: npm --workspace @predecessor/companion run package:win

- name: Upload unsigned test installer
uses: actions/upload-artifact@v7
with:
name: RiftLine-Companion-Windows-unsigned
path: apps/companion/release/RiftLine-Companion-*.exe
if-no-files-found: error
retention-days: 14
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ node_modules/
# Build outputs
dist/
build/
release/
*.js.map

# Environment
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Private competitive intelligence platform for the MOBA [Predecessor](https://www
|-------|------|
| Frontend | React 19 + TypeScript + Vite |
| Backend | Express + Node.js + TypeScript |
| Database | PostgreSQL + Prisma ORM |
| Database | PostgreSQL 16 + TimescaleDB 2.27 + Prisma ORM |
| Auth | Internal (bcrypt + JWT + HTTP-only cookies) + OAuth2 PKCE (pred.gg) |
| Logging | Pino (structured JSON) |
| Tests | Vitest + Supertest (114 tests) |
| Tests | Vitest + Supertest (151 tests) |
| CI/CD | GitHub Actions + branch protection on main |
| Hosting | Hetzner VPS (Nginx + PM2; API + frontend static build) |

Expand All @@ -38,7 +38,7 @@ Private competitive intelligence platform for the MOBA [Predecessor](https://www
- **View As Role** — admins preview UI as any role (sessionStorage, no DB change)
- **Player self-linking** — players link their pred.gg profile from the Dashboard
- **Platform Admin panel** — Staff management, Data Controls, Audit Logs
- **Data retention** — configurable rolling window (default 3 months), monthly auto-cleanup cron
- **Data retention** — configurable global window with permanent preservation for linked players and team rosters
- **Landing page** — hero showcase with Predecessor characters
- **Login fullscreen** — internal email/password; social logins (coming soon)

Expand All @@ -48,7 +48,7 @@ Private competitive intelligence platform for the MOBA [Predecessor](https://www

### Prerequisites
- Node.js 20.12+
- PostgreSQL 15+
- Docker with Compose, or PostgreSQL 16 with TimescaleDB 2.27+
- npm 10+

### Install
Expand All @@ -66,9 +66,9 @@ cp .env.example .env

### Database
```bash
cd workers/data-sync
npx prisma db push # create tables
cd ../..
docker compose -f docker-compose.database.yml up -d
npx prisma migrate deploy --schema workers/data-sync/prisma/schema.prisma

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide a complete baseline before using migrate deploy

On the newly created Docker database from the preceding command, this applies the entire migration history from scratch, but that history does not contain CREATE TABLE statements for several current models. For example, 20260525000001_sprint_roster_status_user_language/migration.sql immediately runs ALTER TABLE "User", while no earlier migration creates User, so the documented local setup aborts before Prisma generation or seeding. Retain the prior schema-push bootstrap or add a complete baseline migration before recommending this command.

Useful? React with 👍 / 👎.

npx prisma generate --schema workers/data-sync/prisma/schema.prisma
npx tsx scripts/seed-config.ts # seed platform config
```

Expand All @@ -81,7 +81,7 @@ npx tsx scripts/seed-config.ts # seed platform config

### Tests
```bash
npm test # 114 tests across API routes + domain engine
npm test # 151 tests across API routes + domain engine
npm run typecheck # TypeScript strict check
```

Expand Down Expand Up @@ -123,6 +123,6 @@ assets/ Static assets (heroes, items, icons, ranks, maps)

## Data retention

Event stream data (kills, wards, transactions) is retained for **3 months** by default. A cron job runs on the 1st of each month at 03:00 AM. Configurable via `DATA_RETENTION_MONTHS` env var. Manual trigger: `POST /admin/cleanup-old-data`.
Unowned match data is retained for **3 months** by default. Matches involving a player linked to a user account or team roster are preserved for longitudinal coaching. Personal imports use a separate **60-month** window by default. Configure these with `DATA_RETENTION_MONTHS` and `PERSONAL_HISTORY_MONTHS`. A cleanup job runs on the 1st of each month at 03:00 AM; manual trigger: `POST /admin/cleanup-old-data`.

Planned next step: TimescaleDB migration for 5-10x compression of event data.
Kills, objectives, structures, wards, and transactions are stored as hypertables with seven-day chunks and automatic compression after 30 days. Local build, matchup, and personal interval aggregates refresh daily and can be rebuilt with `POST /admin/refresh-coach-aggregates`.
14 changes: 14 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ import { commsRouter } from './routes/comms.js';
import { playbookRouter } from './routes/playbook.js';
import { reviewSessionsRouter } from './routes/review-sessions.js';
import { missionsRouter } from './routes/missions.js';
import { playerLearningRouter } from './routes/player-learning.js';
import { errorHandler } from './middleware/error-handler.js';
import { db, disconnectDb } from './db.js';
import { logger } from './logger.js';
import cron from 'node-cron';
import { cleanupOldData } from './services/sync-service.js';
import { refreshCoachAggregates } from './services/coach-aggregate-service.js';

const app = express();
const PORT = parseInt(process.env.PORT ?? '3001', 10);
Expand Down Expand Up @@ -107,6 +109,7 @@ app.use('/comms', commsRouter);
app.use('/playbook', playbookRouter);
app.use('/review-sessions', reviewSessionsRouter);
app.use('/missions', missionsRouter);
app.use('/player-learning', playerLearningRouter);

app.use(errorHandler);

Expand All @@ -130,6 +133,17 @@ if (process.env.NODE_ENV !== 'test') {
logger.error({ err }, 'cron: monthly cleanup failed');
}
});

// Keep local build, matchup and player-interval cohorts fresh without
// depending on pred.gg's restricted population-statistics scopes.
cron.schedule('15 4 * * *', async () => {
try {
const result = await refreshCoachAggregates(db);
logger.info(result, 'cron: coaching aggregates refreshed');
} catch (err) {
logger.error({ err }, 'cron: coaching aggregate refresh failed');
}
});
}

let server: ReturnType<typeof app.listen> | undefined;
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ vi.mock('../db.js', () => ({

vi.mock('../services/predgg-token-service.js', () => ({
getPlatformAccessToken: vi.fn().mockResolvedValue(null),
readPlatformOAuthStatus: vi.fn().mockResolvedValue({
requestedScopes: ['offline_access', 'profile'], grantedScopes: [], missingScopes: ['offline_access', 'profile'],
capabilities: { profile: false, offlineRefresh: false, playerIntervals: false, heroLeaderboard: false, matchupStatistics: false },
checkedAt: null, error: 'Granted scopes have not been inspected yet',
}),
inspectPlatformOAuthStatus: vi.fn(),
platformTokenState: { status: 'missing', lastCheckedAt: null, lastError: null },
}));

Expand All @@ -22,6 +28,8 @@ vi.mock('../services/sync-service.js', () => ({
syncVersionsFromPredgg: vi.fn().mockResolvedValue(5),
syncStalePlayers: vi.fn().mockResolvedValue({ synced: 2, skipped: 0, errors: 0 }),
syncIncompleteMatches: vi.fn().mockResolvedValue({ synced: 1, errors: 0 }),
syncGameCatalog: vi.fn().mockResolvedValue({ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }),
syncTrackedGameCatalogs: vi.fn().mockResolvedValue({ versions: 1, catalogs: [{ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }] }),
repairEventStreamPlayerIds: vi.fn().mockResolvedValue({
heroKillsUpdated: 1,
objectiveKillsUpdated: 1,
Expand Down
51 changes: 45 additions & 6 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ import {
syncMatchEventStream,
resyncMatch,
cleanupOldData,
syncGameCatalog,
syncTrackedGameCatalogs,
} from '../services/sync-service.js';
import { syncHeroMeta } from '../services/hero-meta-service.js';
import { invalidateHeroMetaCache } from './hero-meta.js';
import { getAllConfig, updateConfigValue, updateConfigText, resetConfigValue } from '../services/config-service.js';
import { getPermissions, savePermissions, DEFAULT_PERMISSIONS, CONFIGURABLE_ROLES } from '../services/permissions-service.js';
import { getValidToken } from './auth.js';
import { getPlatformAccessToken, platformTokenState } from '../services/predgg-token-service.js';
import { getPlatformAccessToken, inspectPlatformOAuthStatus, platformTokenState, readPlatformOAuthStatus } from '../services/predgg-token-service.js';
import { requireAuth } from '../middleware/require-auth.js';
import { requirePlatformAdmin } from '../middleware/require-platform-admin.js';
import { refreshCoachAggregates } from '../services/coach-aggregate-service.js';

export const adminRouter = Router();

Expand Down Expand Up @@ -676,6 +679,11 @@ adminRouter.get('/api-status', async (_req, res, next) => {
}

const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const platformToken = await getPlatformAccessToken();
const oauth = platformToken
? await inspectPlatformOAuthStatus(platformToken.accessToken)
: await readPlatformOAuthStatus();

const [totalErrors, recentErrors, lastSuccess, recentBySource] = await Promise.all([
db.syncLog.count({ where: { status: 'error' } }),
db.syncLog.count({ where: { status: 'error', syncedAt: { gte: dayAgo } } }),
Expand All @@ -684,7 +692,7 @@ adminRouter.get('/api-status', async (_req, res, next) => {
]);

res.json({
predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL },
predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL, oauth },
syncErrors: { total: totalErrors, last24h: recentErrors, bySource: recentBySource },
lastSuccessfulSync: lastSuccess,
});
Expand All @@ -705,6 +713,19 @@ adminRouter.post('/sync-heroes', requireAuth, requirePlatformAdmin, async (_req,
}
});

/** POST /admin/sync-game-catalog — current or selected patch catalog. */
adminRouter.post('/sync-game-catalog', requireAuth, requirePlatformAdmin, async (req, res, next) => {
try {
const { versionId } = z.object({ versionId: z.string().optional() }).parse(req.body ?? {});
const result = versionId
? await syncGameCatalog(db, versionId)
: await syncTrackedGameCatalogs(db);
res.json({ ok: true, ...result });
} catch (err) {
next(err);
}
});

/**
* GET /admin/config
* Returns all platform config entries.
Expand Down Expand Up @@ -854,6 +875,15 @@ adminRouter.post('/cleanup-old-data', async (_req, res, next) => {
} catch (err) { next(err); }
});

/** POST /admin/refresh-coach-aggregates — rebuilds RiftLine-owned coaching cohorts. */
adminRouter.post('/refresh-coach-aggregates', async (_req, res, next) => {
try {
const startedAt = Date.now();
const result = await refreshCoachAggregates(db);
res.json({ ok: true, ...result, elapsedMs: Date.now() - startedAt });
} catch (err) { next(err); }
});

/**
* POST /admin/cleanup-non-team-data
* Deletes Match records (+ cascade: MatchPlayer, HeroKill, ObjectiveKill, WardEvent,
Expand All @@ -871,11 +901,20 @@ adminRouter.post('/cleanup-non-team-data', async (_req, res, next) => {

logger.info({ teamPlayerCount: teamPlayerIds.length }, 'admin: cleanup-non-team-data — finding orphan matches');

// Find matches where none of the MatchPlayers belong to a team player
// Find matches where none of the MatchPlayers belong to a team player or
// to a standalone user. Personal coaching history is never orphan data.
const linkedUsers = await db.user.findMany({
where: { linkedPlayerId: { not: null } },
select: { linkedPlayerId: true },
});
const protectedPlayerIds = [...new Set([
...teamPlayerIds,
...linkedUsers.map((user) => user.linkedPlayerId).filter((id): id is string => id != null),
])];
const orphanMatches = await db.match.findMany({
where: {
matchPlayers: {
none: { playerId: { in: teamPlayerIds } },
none: { playerId: { in: protectedPlayerIds } },
},
},
select: { id: true },
Expand All @@ -896,12 +935,12 @@ adminRouter.post('/cleanup-non-team-data', async (_req, res, next) => {
// Also clean up PlayerSnapshot records for players not on any team
const snapResult = await db.playerSnapshot.deleteMany({
where: {
playerId: { notIn: teamPlayerIds },
playerId: { notIn: protectedPlayerIds },
},
});

logger.info({ deletedMatches: deleted, deletedSnapshots: snapResult.count }, 'admin: cleanup-non-team-data complete');
res.json({ ok: true, deletedMatches: deleted, deletedSnapshots: snapResult.count, teamPlayers: teamPlayerIds.length });
res.json({ ok: true, deletedMatches: deleted, deletedSnapshots: snapResult.count, protectedPlayers: protectedPlayerIds.length });
} catch (err) { next(err); }
});

Expand Down
23 changes: 21 additions & 2 deletions apps/api/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import request from 'supertest';
import express from 'express';
import cookieParser from 'cookie-parser';
import { authRouter } from './auth.js';
import { savePlatformOAuthTokens } from '../services/predgg-token-service.js';
import { authRouter, getValidToken } from './auth.js';
import { getPlatformAccessToken, savePlatformOAuthTokens } from '../services/predgg-token-service.js';

vi.hoisted(() => {
process.env.PRED_GG_CLIENT_ID = 'test-client-id';
Expand All @@ -22,9 +22,28 @@ vi.mock('../services/predgg-token-service.js', () => ({

const app = express();
app.use(cookieParser());
app.get('/token-probe', async (req, res) => {
const token = await getValidToken(req, res);
res.json({ token: token ?? null });
});
app.use('/auth', authRouter);

describe('GET /auth/predgg', () => {
it('clears an expired browser session when silent refresh is unavailable', async () => {
vi.mocked(getPlatformAccessToken).mockResolvedValueOnce(null);

const res = await request(app)
.get('/token-probe')
.set('Cookie', ['predgg_expires_at=1']);

expect(res.status).toBe(200);
expect(res.body).toEqual({ token: null });
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
expect.stringContaining('predgg_token='),
expect.stringContaining('predgg_expires_at='),
]));
});

it('redirects to the pred.gg OAuth SPA authorize route with PKCE', async () => {
const res = await request(app).get('/auth/predgg');

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function getValidToken(req: Request, res: Response): Promise<string
return platformToken.accessToken;
}

logger.warn({ error: result.data.error }, 'silent refresh failed — user must re-login');
logger.warn('silent refresh failed — user must re-login');
// Clear stale cookies so /auth/me returns unauthenticated
res.clearCookie(COOKIE_TOKEN);
res.clearCookie(COOKIE_EXPIRES_AT);
Expand Down
Loading