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
@@ -0,0 +1,253 @@
import { onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { FPI_AXES, type FpiAxis } from '../core/config.js';
import { passportRank, type PassportTier } from '../core/level.js';
/**
* 팬덤별 Passport Tier · 활동 Badge — 상세정의 v1.6.1 **4장** · 별첨 B 「Passport Tier」「사용자 Level/Badge」
* 「팬덤 최초 100명」 · 9/21 회신 **5절** · 10/3 통합회신 **#10** · UX·FX 정렬기준 v1.5 **§7**.
*
* - **Tier 는 사용자 × 팬덤 단위**다(10/3 #10). User Level · Badge · Role 과 별개이며 강등하지 않고
* 탈퇴 후에도 보존한다. 멤버 문서(`fandoms/{f}/members/{uid}`)는 탈퇴 때 지워지므로 Passport 는
* 지우지 않는 `users/{uid}/passports/{fandomId}` 에 둔다 — 재가입하면 같은 문서를 이어 쓴다(9/21 5절).
* - **Badge 는 활동 카테고리별 7단계**(상세정의 4장, 초기 임계치 10/100/500/1,500/4,000/10,000/30,000,
* Badge 별 조정). 사용자 공통 값이다 — `users/{uid}/badges/{badgeId}`.
* - 진화 기념 · 최초 100명 기념 Badge 는 POP/FPI/권한 보너스가 없다(별첨 B).
*/
/** 상세정의 4장 「Badge 는 활동 카테고리별 7단계. 초기 임계치」. Badge 마스터가 덮는다. */
export const BADGE_STAGE_THRESHOLDS = [10, 100, 500, 1_500, 4_000, 10_000, 30_000] as const;
export interface ActivityBadgeDef {
id: string;
/** 집계 카테고리. `users.activityCounts.{axis}` 를 센다. */
axis: FpiAxis;
thresholds: number[];
}
/**
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A **Q-A08**). 상세정의 4장은 「활동 카테고리별」
* 이라고만 하고 카테고리 목록을 주지 않는다. 서버가 인정한 활동이 모이는 FPI 5축(별첨 B 「FPI 5축」)을
* 카테고리로 두었다. `badges/{id}`(kind `activity`) 마스터가 있으면 그것이 우선한다(Admin 「Badge 관리」).
*/
export const DEFAULT_ACTIVITY_BADGES: ActivityBadgeDef[] = FPI_AXES.map((axis) => ({
id: `activity_${axis}`, axis, thresholds: [...BADGE_STAGE_THRESHOLDS],
}));
/** 누적 [count] 가 도달한 단계(0 = 미획득, 1~7). */
export function badgeStageOf(thresholds: readonly number[], count: number): number {
let stage = 0;
for (const t of thresholds) {
if (count >= t) stage += 1;
else break;
}
return stage;
}
export interface TierRule {
tier: Exclude<PassportTier, 'SILVER'>;
minUserLevel: number;
/** 그 팬덤 첫 가입 이후 일수. */
minActiveDays: number;
/** [minBadgeStage] 이상인 Badge 수. */
minBadges: number;
minBadgeStage: number;
/** 그 팬덤 누적 기여 POP. */
minContributedPop: number;
enabled: boolean;
}
/**
* Passport Tier 승급 규칙 — `config/passportTierRules.rules` 가 덮는다(Firestore 설계서 §2.17 · C-133).
*
* 🟡 **임시값 — 회신 대기**(영역A **Q-A07**). 상세정의 4장 초기 기준 「Gold Lv.30 이상 + 활동기간 + 핵심 Badge /
* Diamond Lv.70 이상 + 장기활동 + 복수 상위 Badge + 누적기여. 세부조건 Admin 조정」 중 Level 만 수치가 있다.
* 활동기간 30/180일 · 누적기여 100,000 POP 은 스위치 제안값이며 10/3 #10 이 「확정하지 않음 · Admin 운영값으로
* 구현하고 별도 확정」으로 남겼다. 「핵심 Badge」 1개 · 「복수 상위 Badge」 3단계 이상 2개는 우리가 둔 값이다.
*/
export const DEFAULT_TIER_RULES: TierRule[] = [
{ tier: 'GOLD', minUserLevel: 30, minActiveDays: 30, minBadges: 1, minBadgeStage: 1,
minContributedPop: 0, enabled: true },
{ tier: 'DIAMOND', minUserLevel: 70, minActiveDays: 180, minBadges: 2, minBadgeStage: 3,
minContributedPop: 100_000, enabled: true },
];
export interface TierInputs {
userLevel: number;
activeDays: number;
badgeStages: number[];
contributedPop: number;
}
/** 규칙을 만족하는 가장 높은 Tier. **강등하지 않는다** — [current] 보다 낮으면 [current]. */
export function tierOf(rules: TierRule[], inputs: TierInputs, current?: string | null): PassportTier {
let best: PassportTier = 'SILVER';
for (const rule of rules) {
if (!rule.enabled) continue;
const badges = inputs.badgeStages.filter((s) => s >= rule.minBadgeStage).length;
const ok = inputs.userLevel >= rule.minUserLevel && inputs.activeDays >= rule.minActiveDays &&
badges >= rule.minBadges && inputs.contributedPop >= rule.minContributedPop;
if (ok && passportRank(rule.tier) > passportRank(best)) best = rule.tier;
}
return passportRank(current) > passportRank(best) ? current as PassportTier : best;
}
async function loadTierRules(): Promise<TierRule[]> {
const raw = (await getFirestore().doc('config/passportTierRules').get()).get('rules');
if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_TIER_RULES;
return raw.filter((r) => r && (r.tier === 'GOLD' || r.tier === 'DIAMOND')).map((r) => ({
...DEFAULT_TIER_RULES.find((d) => d.tier === r.tier)!, ...r,
}));
}
async function loadActivityBadges(): Promise<ActivityBadgeDef[]> {
const snap = await getFirestore().collection('badges').where('kind', '==', 'activity').get();
const defs = snap.docs.flatMap((d) => {
const axis = d.get('axis');
const thresholds = d.get('thresholds');
return FPI_AXES.includes(axis) && Array.isArray(thresholds) && thresholds.every(Number.isFinite)
? [{ id: d.id, axis, thresholds }] : [];
});
return defs.length > 0 ? defs : DEFAULT_ACTIVITY_BADGES;
}
const DAY_MS = 86_400_000;
/** 기념 Badge 한 건 — 이미 있으면 그대로 둔다(재전달 · 재가입에도 한 번). */
export async function awardBadge(
uid: string, badgeId: string, data: Record<string, unknown>,
): Promise<void> {
try {
await getFirestore().doc(`users/${uid}/badges/${badgeId}`).create({
badgeId, stage: 1, ...data,
awardedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
});
} catch (error) {
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
}
}
/** 활동 카운터로 활동 Badge 단계를 올린다. 내리지 않는다. */
export async function awardActivityBadges(uid: string, counts: Record<string, unknown>): Promise<void> {
const db = getFirestore();
const defs = await loadActivityBadges();
await Promise.all(defs.map((def) => db.runTransaction(async (tx) => {
const ref = db.doc(`users/${uid}/badges/${def.id}`);
const count = Number(counts[def.axis] ?? 0);
const stage = badgeStageOf(def.thresholds, Number.isFinite(count) ? count : 0);
const snap = await tx.get(ref);
const prev = Number(snap.get('stage') ?? 0);
if (stage <= prev) return;
tx.set(ref, {
badgeId: def.id, kind: 'activity', axis: def.axis, stage, count,
...(snap.exists ? {} : { awardedAt: FieldValue.serverTimestamp() }),
stageChangedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
})));
}
/**
* 한 팬덤의 Passport 를 다시 산출한다. 가입 중일 때만 Tier 를 올린다(탈퇴 중에는 보존만).
* 처음이면 첫 가입 시각 · 순번을 남긴다 — 재가입해도 새 Passport 로 초기화하지 않는다(9/21 5절).
*/
export async function refreshPassport(uid: string, fandomId: string): Promise<PassportTier | null> {
const db = getFirestore();
const [rules, badges] = await Promise.all([
loadTierRules(), db.collection(`users/${uid}/badges`).get(),
]);
const badgeStages = badges.docs.map((d) => Number(d.get('stage') ?? 0));
return db.runTransaction(async (tx) => {
const passportRef = db.doc(`users/${uid}/passports/${fandomId}`);
const [user, member, passport] = await Promise.all([
tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(passportRef),
]);
if (!member.exists) return (passport.get('tier') as PassportTier | undefined) ?? null;
const joinedAt = member.get('joinedAt');
const firstJoinedAt = passport.get('firstJoinedAt') instanceof Timestamp
? passport.get('firstJoinedAt') as Timestamp
: joinedAt instanceof Timestamp ? joinedAt : Timestamp.now();
// ponytail: 첫 가입부터 센다(탈퇴 기간 포함). 탈퇴 기간을 빼려면 가입 · 탈퇴 이력이 필요하다.
const activeDays = Math.floor((Date.now() - firstJoinedAt.toMillis()) / DAY_MS);
const current = passport.get('tier') as string | undefined;
const contributedPop = Number(member.get('contributedPop') ?? 0);
const tier = tierOf(rules, {
userLevel: Number(user.get('userLevel') ?? 1),
activeDays, badgeStages, contributedPop,
}, current);
if (passport.exists && current === tier && passport.get('activeDays') === activeDays &&
passport.get('contributedPop') === contributedPop) return tier;
// 누적 기여도 남긴다 — 멤버 문서는 탈퇴 때 지워지지만 Passport 기록은 보존한다(상세정의 4장 · 5장).
tx.set(passportRef, {
fandomId, tier, activeDays, contributedPop,
...(passport.exists ? {} : {
firstJoinedAt, memberNo: member.get('memberNo') ?? null, createdAt: FieldValue.serverTimestamp(),
}),
...(current !== tier ? { tierChangedAt: FieldValue.serverTimestamp() } : {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return tier;
});
}
/** 레벨 · 활동 카운터가 바뀌면 Badge 를 올리고 가입한 모든 팬덤의 Passport 를 다시 본다. */
export const fnPassportOnUserUpdate = onDocumentUpdated(
{ document: 'users/{uid}', retry: true },
async (event) => {
const change = event.data;
if (!change) return;
const uid = event.params.uid as string;
const countsChanged = JSON.stringify(change.before.get('activityCounts') ?? {}) !==
JSON.stringify(change.after.get('activityCounts') ?? {});
const levelChanged = change.before.get('userLevel') !== change.after.get('userLevel');
if (!countsChanged && !levelChanged) return;
if (countsChanged) await awardActivityBadges(uid, change.after.get('activityCounts') ?? {});
const fandomIds = change.after.get('fandomIds');
if (!Array.isArray(fandomIds)) return;
for (const fandomId of fandomIds) {
if (typeof fandomId === 'string' && fandomId && !fandomId.includes('/')) await refreshPassport(uid, fandomId);
}
},
);
/**
* 가입 · 기여가 바뀌면 그 팬덤 Passport 를 다시 본다. 처음 가입이면서 순번이 #001~#100 이면
* 최초 100명 기념 Badge(별첨 B 「팬덤 최초 100명」 · 10/3 회신 성운 「기념 Badge 기준」).
*/
export const fnPassportOnMemberWrite = onDocumentWritten(
{ document: 'fandoms/{fandomId}/members/{uid}', retry: true },
async (event) => {
const change = event.data;
if (!change?.after.exists) return;
const { fandomId, uid } = event.params as { fandomId: string; uid: string };
const created = !change.before.exists;
if (!created && change.before.get('contributedPop') === change.after.get('contributedPop')) return;
if (created) {
const memberNo = Number(change.after.get('memberNo'));
if (Number.isInteger(memberNo) && memberNo >= 1 && memberNo <= 100) {
await awardBadge(uid, `founder100_${fandomId}`, {
kind: 'founder100', fandomId, memberNo,
stageAtJoin: (await getFirestore().doc(`fandoms/${fandomId}`).get()).get('stage') ?? null,
});
}
}
await refreshPassport(uid, fandomId);
},
);
/**
* 활동기간 조건은 시간이 지나야 채워진다 — 하루 한 번 가입 중인 모든 멤버십의 Passport 를 다시 본다.
* 배포 전부터 있던 멤버십(Passport 문서 없음)도 첫 실행에서 만들어진다.
*/
export const fnPassportDaily = onSchedule({ schedule: 'every day 04:30', timeZone: 'Asia/Seoul' }, async () => {
// ponytail: 전수 스캔. 사용자가 늘면 일자별 샤딩으로 나눈다.
const snap = await getFirestore().collectionGroup('members').select().get();
for (const doc of snap.docs) {
const fandomId = doc.ref.parent.parent?.id;
if (fandomId) await refreshPassport(doc.id, fandomId);
}
logger.info('[passport] 일일 재산출', { scanned: snap.size });
});
+1 -12
View File
@@ -53,18 +53,7 @@ export function titleOf(level: number): Title {
const PASSPORT_ORDER: readonly PassportTier[] = ['SILVER', 'GOLD', 'DIAMOND'];
/**
* Passport Tier — Silver 기본 / Gold Lv.30+ / Diamond Lv.70+(B-65 비고 · v1.6.1 4장 초기 기준, 🟡 운영값).
*
* **강등하지 않는다** — `current` 보다 낮은 값은 돌려주지 않는다. B-65 의 다른 입력
* (활동기간 · Badge · 누적기여)은 조건이 S01 미확정이라 아직 쓰지 않는다.
*/
export function passportTierOf(level: number, current?: PassportTier | null): PassportTier {
const byLevel: PassportTier = level >= 70 ? 'DIAMOND' : level >= 30 ? 'GOLD' : 'SILVER';
const kept = current && PASSPORT_ORDER.includes(current) ? current : 'SILVER';
return passportRank(byLevel) >= passportRank(kept) ? byLevel : kept;
}
/** Tier 순위(강등 판정용). 산출은 팬덤별 — `account/passport.ts` `tierOf`. */
export function passportRank(tier: string | undefined | null): number {
return PASSPORT_ORDER.indexOf(tier as PassportTier);
}
+60 -3
View File
@@ -1,13 +1,14 @@
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
import { onCall } from 'firebase-functions/v2/https';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { sendFandomPush } from '../notify/push.js';
import { awardBadge } from '../account/passport.js';
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존하고, 진화 주기 기여자에게 기념 Emblem 을 준다(상세정의 22장). */
export const fnPlanetEvolutionRecord = onDocumentUpdated(
{ document: 'fandoms/{fandomId}', retry: true },
async (event) => {
@@ -35,9 +36,64 @@ export const fnPlanetEvolutionRecord = onDocumentUpdated(
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
}
const startedAt = change.before.get('stageChangedAt') ?? change.before.get('createdAt');
await awardEvolutionEmblems({
fandomId: change.after.id,
name: change.after.get('name') as string | undefined,
fromStage: from, toStage: to,
since: startedAt instanceof Timestamp ? startedAt : Timestamp.fromMillis(0),
until: change.after.updateTime ?? Timestamp.now(),
});
},
);
/**
* 진화 기념 Emblem — 상세정의 v1.6.1 **22장** 「진화가 완료되면 해당 진화 주기에 유효 기여한 사용자에게
* Passport 용 기념 Emblem 을 자동 지급」 · 별첨 B 「진화 참여자는 기념 Badge 만 지급」(POP/FPI/권한 보너스 없음).
*
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A **Q-A09**). 「유효 기여」의 기간 · 최소량은 자료에
* 없다. 직전 Stage 진입 ~ 진화 시각 사이 그 팬덤에 귀속된 성장 기여(growthEvents) 1건 이상으로 둔다.
*
* 참여자에게만 「당신도 이 진화에 함께했습니다」(9/21 6절 · FX §2) 알림을 남긴다 — 팬덤 토픽 푸시는
* 참여 여부를 모르므로 단정하지 않는다.
*/
export async function awardEvolutionEmblems(input: {
fandomId: string; name?: string | undefined; fromStage: number; toStage: number; since: Timestamp; until: Timestamp;
}): Promise<string[]> {
const db = getFirestore();
const events = await db.collectionGroup('growthEvents')
.where('fandomIdAtPayout', '==', input.fandomId)
.where('createdAt', '>=', input.since)
.where('createdAt', '<=', input.until)
.get();
const uids = [...new Set(events.docs
.filter((d) => Number(d.get('amount') ?? 0) > 0 && !d.get('reversalOf'))
.map((d) => d.ref.parent.parent?.id)
.filter((uid): uid is string => !!uid))];
for (const uid of uids) {
const id = `evolution_${input.fandomId}_${input.toStage}`;
await awardBadge(uid, id, {
kind: 'evolution', fandomId: input.fandomId,
fromStage: input.fromStage, toStage: input.toStage, evolvedAt: input.until,
});
try {
await db.doc(`users/${uid}/notifications/${id}`).create({
type: 'fandomEvent',
eventType: 'evolution',
title: `${input.name ?? ''} 행성이 「${STAGE_NAMES[input.toStage]}」 단계로 진화했습니다`.trim(),
body: '당신도 이 진화에 함께했습니다. Passport 기록에서 확인해 보세요.',
fandomId: input.fandomId,
linkTo: `/passport/${input.fandomId}`,
read: false,
createdAt: FieldValue.serverTimestamp(),
});
} catch (error) {
if ((error as { code?: number }).code !== 6) throw error;
}
}
return uids;
}
/** 별첨 B **B-53** — 정식 행성 6 단계 명칭. 성운(Stage0)은 `fn-planet-create` 가 다룬다. */
const STAGE_NAMES = ['', '시작', '성장', '함께', '동행', '빛', '영원'];
const FINAL_STAGE = 6;
@@ -99,7 +155,8 @@ export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]>
fandomId: ref.id,
category: 'fandomEvent',
title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`,
body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.',
// 토픽은 팬덤 전원이라 참여 여부를 단정하지 않는다. 참여자 문구는 알림함(awardEvolutionEmblems).
body: '팬덤이 함께 만든 진화입니다. 행성에서 확인해 보세요.',
data: { kind: 'evolution', toStage: String(promotion.toStage) },
});
}
+27 -1
View File
@@ -609,13 +609,39 @@ async function closeExpiredEclipses(
defended, qualifiedParticipants: qualified,
endedAt: occurredAt, ...(defended ? {} : { appliedAt: occurredAt }),
});
return true;
return defended ? 'success' : true;
});
if (changed) closed++;
if (changed === 'success') await recordEclipseSuccess(fandomRef.id, doc.ref);
}
return closed;
}
/**
* Eclipse 성공 참여 PASSPORT 기록 — UX·FX 정렬기준 v1.5 **§5.2** 「인정 횟수(60회) 이상 + 최종 성공 시 회차당
* 1회 성공 참여 기록」. 신규 Badge type 을 만들지 않고 PASSPORT 기록으로 남긴다(§5.2 「Badge 확장은 별도 검토」).
* 문서 ID 가 회차라 재실행에도 한 번이다.
*/
export async function recordEclipseSuccess(
fandomId: string, roundRef: FirebaseFirestore.DocumentReference,
): Promise<number> {
const db = getFirestore();
const qualified = await roundRef.collection('participants').where('qualified', '==', true).get();
for (const p of qualified.docs) {
try {
await db.doc(`users/${p.id}/passportHistory/eclipse_${fandomId}_${roundRef.id}`).create({
kind: 'eclipse_success', fandomId, eclipseId: roundRef.id,
validShakes: p.get('validShakes') ?? null,
occurredAt: FieldValue.serverTimestamp(),
});
} catch (error) {
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
}
}
return qualified.size;
}
/**
* `fn-fpi-aggregate` — FPI 집계(§4) · **1 분 주기**(별첨 B 「FPI 집계」 확정).
*
+27 -2
View File
@@ -31,12 +31,36 @@ export const fnPlanetHistory = onCall(async (request) => {
}
query = query.startAfter(previous);
}
const [page, contribution] = await Promise.all([
// PASSPORT Stamp — 진화 기념 Emblem · 최초 100명 Badge · Eclipse 성공 참여(FX §5.2 · §7.2 「역사적 사건」).
// 개인 범위 첫 장에만 붙인다. 양이 적고(회차·진화 단위) 성장 원장과 커서가 다르다.
const firstPersonalPage = personal && request.data?.cursor == null;
const [page, contribution, stamps, badges] = await Promise.all([
query.get(),
// 성장 이벤트는 실제 지급/기여 원장과 같은 트랜잭션에서 생성된다.
personal ? Promise.resolve(null) : db.collection(`users/${uid}/growthEvents`)
.where('fandomIdAtPayout', '==', fandomId).orderBy('createdAt').limit(1).get(),
firstPersonalPage ? db.collection(`users/${uid}/passportHistory`).where('fandomId', '==', fandomId).get() : null,
firstPersonalPage ? db.collection(`users/${uid}/badges`).where('fandomId', '==', fandomId).get() : null,
]);
const stampItems = [
...(stamps?.docs ?? []).flatMap((doc) => {
const at = doc.get('occurredAt');
return doc.get('kind') === 'eclipse_success' && at instanceof Timestamp
? [{ id: doc.id, kind: 'eclipse_success', occurredAt: at.toDate().toISOString(), participationConfirmed: true }]
: [];
}),
...(badges?.docs ?? []).flatMap((doc) => {
const at = doc.get('awardedAt');
const kind = doc.get('kind');
if (!(at instanceof Timestamp) || (kind !== 'evolution' && kind !== 'founder100')) return [];
return [{
id: doc.id, kind: kind === 'evolution' ? 'emblem_evolution' : 'badge_founder100',
occurredAt: at.toDate().toISOString(),
...(kind === 'evolution' ? { toStage: doc.get('toStage') } : { memberNo: doc.get('memberNo') }),
participationConfirmed: true,
}];
}),
];
const first = contribution?.docs[0];
const firstAt = first?.get('createdAt');
const hasProof = firstAt instanceof Timestamp && Number.isSafeInteger(first?.get('amount')) &&
@@ -65,5 +89,6 @@ export const fnPlanetHistory = onCall(async (request) => {
}];
});
// 원인 이벤트 ID·운영 조건·원장·다른 사용자의 식별자는 공개 응답에 포함하지 않는다.
return { items, nextCursor: page.size > 20 ? rows.at(-1)!.id : null };
const all: Array<{ id: string; occurredAt: string; [key: string]: unknown }> = [...stampItems, ...items].sort((x, y) => y.occurredAt.localeCompare(x.occurredAt));
return { items: all, nextCursor: page.size > 20 ? rows.at(-1)!.id : null };
});
+1
View File
@@ -52,3 +52,4 @@ export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
export { fnPassportOnUserUpdate, fnPassportOnMemberWrite, fnPassportDaily } from './account/passport.js';
+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(),