Passport Tier 팬덤 단위 · 활동 Badge · 진화 Emblem · Eclipse 참여 기록

This commit is contained in:
Sn-Kinos
2026-10-05 22:09:10 +09:00
parent 71ec17dc3c
commit cc0f2fb292
36 changed files with 877 additions and 81 deletions
+5 -2
View File
@@ -2,7 +2,7 @@ import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import type { DocumentSnapshot, Transaction } from 'firebase-admin/firestore';
import type { FpiAxis } from '../core/config.js';
import { levelOf, passportTierOf, type PassportTier } from '../core/level.js';
import { levelOf } from '../core/level.js';
/**
* FPI 원천 이벤트 — 정책 원장 **C-34 · C-35 · G-05**, 설계서 §4.1.1.
@@ -79,6 +79,10 @@ export function writeFpiEvent(
): void {
if (event.units <= 0) return;
if (event.grantsExp !== false) writeUserExp(tx, event.uid, user);
// 활동 카테고리별 누적 — 활동 Badge 7단계의 입력(상세정의 4장 · `account/passport.ts`).
tx.set(getFirestore().doc(`users/${event.uid}`), {
activityCounts: { [event.axis]: FieldValue.increment(event.units) },
}, { merge: true });
if (!event.fandomId) return;
tx.create(
getFirestore().doc(`fandoms/${event.fandomId}/fpiEvents/${event.uid}_${event.sourceId}`),
@@ -109,7 +113,6 @@ export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapsho
tx.set(getFirestore().doc(`users/${uid}`), {
userExp: exp,
userLevel: level,
passportTier: passportTierOf(level, user?.get('passportTier') as PassportTier | undefined),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
+2 -14
View File
@@ -3,7 +3,7 @@ import type { DocumentReference, DocumentSnapshot, Firestore, Transaction } from
import { ERR, fail } from '../core/errors.js';
import { lotIdOf, txIdOf } from '../core/ids.js';
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
import { levelOf } from '../core/level.js';
import { fandomOfUser, isMemberOf, voidCompletions, writeFpiEvent } from './fpi.js';
import { publicNameOf } from '../core/names.js';
@@ -809,7 +809,7 @@ export async function reconcileUser(uid: string): Promise<ReconcileRow> {
actual: statsActual,
});
// User Level ↔ EXP, Passport Tier 단조(설계서 §3.4 · B-65 · B-66). 두 체계를 따로 본다.
// User Level ↔ EXP(설계서 §3.4 · B-66). Passport Tier 는 팬덤별이라 `account/passport.ts` 가 단조로 산출한다.
const storedLevel = userSnap.get('userLevel') as number | undefined;
if (storedLevel !== undefined) {
checks.push({
@@ -818,18 +818,6 @@ export async function reconcileUser(uid: string): Promise<ReconcileRow> {
actual: storedLevel,
});
}
const storedPassport = userSnap.get('passportTier') as PassportTier | undefined;
if (storedPassport !== undefined) {
// 레벨이 요구하는 등급보다 낮게 저장돼 있으면(강등 · 누락) 불일치다.
checks.push({
name: 'passport-monotonic',
// 레벨 위조가 이 검사까지 오염시키지 않도록 저장된 레벨이 아니라 EXP 로 본다.
expected: passportRank(passportTierOf(
levelOf((userSnap.get('userExp') as number | undefined) ?? 0), storedPassport)),
actual: passportRank(storedPassport),
});
}
const ok = checks.every((c) => c.expected === c.actual);
await walletRef(db, uid).set({
lastReconciledAt: Timestamp.now(),