- character-builder/: Vue 3 + NestJS + Valkey app for planning house, class, character XP, 5 spec tracks, faction standing, and skill trees. Shareable via short link (POST /api/builds → 8-char nanoid). - character-builder/data/: parsed JSON tables (character XP through L200, 5 specs to L100, 2 faction standing tables, 5 class skill trees). - character-builder/scripts/extract.py: parser that regenerates data/*.json from the gitignored sample-data/*.html snapshots. - Dockerfile + docker-compose.yml: two-container deploy (app + Valkey). - specialization-calculator/: pre-existing single-file XP/quest calculator, carried into the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 lines
1.8 KiB
TypeScript
56 lines
1.8 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Inject,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { customAlphabet } from 'nanoid';
|
|
import type Redis from 'ioredis';
|
|
import { VALKEY } from '../valkey.provider';
|
|
|
|
// URL-safe, unambiguous-ish alphabet (no 0/O/1/I/l). 8 chars = 38^8 ≈ 4.4e12 codes.
|
|
const nanoid = customAlphabet(
|
|
'23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz',
|
|
8,
|
|
);
|
|
|
|
const MAX_BYTES = 16 * 1024; // 16 KB cap per shared build
|
|
const TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year
|
|
|
|
@Injectable()
|
|
export class BuildsService {
|
|
constructor(@Inject(VALKEY) private readonly redis: Redis) {}
|
|
|
|
async save(payload: unknown): Promise<{ code: string }> {
|
|
const json = JSON.stringify(payload);
|
|
if (!json || json === 'null') {
|
|
throw new BadRequestException('empty build');
|
|
}
|
|
if (Buffer.byteLength(json, 'utf8') > MAX_BYTES) {
|
|
throw new BadRequestException(`build too large (>${MAX_BYTES} bytes)`);
|
|
}
|
|
// Up to 5 retries on collision.
|
|
for (let i = 0; i < 5; i++) {
|
|
const code = nanoid();
|
|
const key = `build:${code}`;
|
|
const ok = await this.redis.set(key, json, 'EX', TTL_SECONDS, 'NX');
|
|
if (ok === 'OK') return { code };
|
|
}
|
|
throw new BadRequestException('failed to allocate code');
|
|
}
|
|
|
|
async load(code: string): Promise<unknown> {
|
|
if (!/^[2-9A-HJ-NP-Za-km-z]{8}$/.test(code)) {
|
|
throw new BadRequestException('invalid code');
|
|
}
|
|
const json = await this.redis.get(`build:${code}`);
|
|
if (!json) throw new NotFoundException('build not found');
|
|
// refresh TTL so popular builds stay alive
|
|
await this.redis.expire(`build:${code}`, TTL_SECONDS);
|
|
try {
|
|
return JSON.parse(json);
|
|
} catch {
|
|
throw new BadRequestException('corrupt build');
|
|
}
|
|
}
|
|
}
|