Passport Tier 팬덤 단위 · 활동 Badge · 진화 Emblem · Eclipse 참여 기록
This commit is contained in:
@@ -289,6 +289,19 @@ service cloud.firestore {
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 팬덤별 Passport(Tier · 첫 가입 · 순번). 산출은 서버(`account/passport.ts`), 탈퇴해도 남는다.
|
||||
// 화면: MY Passport 카드(팬덤 전환) — 9/21 회신 5절 · 10/3 #10
|
||||
match /users/{uid}/passports/{fandomId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// PASSPORT 역사 기록(Eclipse 성공 참여 등 — FX §5.2). 서버만 쓴다.
|
||||
match /users/{uid}/passportHistory/{id} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 구독(Focus) 상태. 영수증 검증 결과라 서버 전용.
|
||||
// 화면: S-M-FOCUS
|
||||
match /users/{uid}/subscription/{docId} {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 집계」 확정).
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -245,11 +245,6 @@ describe('대사 — 별첨 1 ② 3자 교차 대조', () => {
|
||||
await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true });
|
||||
expect(await failedNames()).toEqual(['level-vs-exp']);
|
||||
|
||||
// 레벨을 되돌리고 EXP 를 Lv.30 이상으로 올렸는데 Passport 가 SILVER 로 남은 상태(강등 · 누락).
|
||||
await getFirestore().doc(`users/${UID}`).set({
|
||||
userExp: 500_000, userLevel: 31, passportTier: 'SILVER',
|
||||
}, { merge: true });
|
||||
expect(await failedNames()).toEqual(['passport-monotonic']);
|
||||
});
|
||||
|
||||
it('지갑을 몰래 고치면 대사가 잡아낸다', async () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { awardActivityBadges, refreshPassport } from '../src/account/passport.js';
|
||||
import { awardEvolutionEmblems } from '../src/fandom/evolution.js';
|
||||
import { grantPop } from '../src/ledger/ledger.js';
|
||||
|
||||
/** 팬덤별 Passport Tier · 활동 Badge · 진화 Emblem — 상세정의 4장 · 22장 · 9/21 5절 · 10/3 #10. */
|
||||
const uid = 'test-passport-user';
|
||||
const other = 'test-passport-other';
|
||||
const a = 'test-passport-a';
|
||||
const b = 'test-passport-b';
|
||||
const DAY = 86_400_000;
|
||||
|
||||
async function clean() {
|
||||
const db = getFirestore();
|
||||
for (const path of [`users/${uid}`, `users/${other}`, `fandoms/${a}`, `fandoms/${b}`]) {
|
||||
await db.recursiveDelete(db.doc(path));
|
||||
}
|
||||
await db.recursiveDelete(db.doc('config/passportTierRules'));
|
||||
}
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
beforeEach(async () => {
|
||||
await clean();
|
||||
const db = getFirestore();
|
||||
await db.doc(`users/${uid}`).set({ userLevel: 30, fandomIds: [a, b], primaryFandomId: a });
|
||||
for (const f of [a, b]) {
|
||||
await db.doc(`fandoms/${f}/members/${uid}`).set({
|
||||
uid, fandomId: f, memberNo: 7, contributedPop: 0,
|
||||
joinedAt: Timestamp.fromMillis(Date.now() - 40 * DAY),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => { await clean(); await shutdownAdmin(); });
|
||||
|
||||
it('활동 카운터가 7단계 문턱을 넘으면 Badge 단계가 오르고 내려가지 않는다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 120, bpm: 5 });
|
||||
const health = await db.doc(`users/${uid}/badges/activity_health`).get();
|
||||
expect(health.get('stage')).toBe(2);
|
||||
expect((await db.doc(`users/${uid}/badges/activity_bpm`).get()).exists).toBe(false);
|
||||
await awardActivityBadges(uid, { health: 10 });
|
||||
expect((await db.doc(`users/${uid}/badges/activity_health`).get()).get('stage')).toBe(2);
|
||||
});
|
||||
|
||||
it('Tier 는 팬덤별 — 같은 사용자라도 기여가 다른 팬덤은 Tier 가 따로다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 500, community: 500 });
|
||||
await db.doc('config/passportTierRules').set({ rules: [
|
||||
{ tier: 'GOLD', minUserLevel: 30, minActiveDays: 30, minBadges: 1, minBadgeStage: 1, minContributedPop: 0, enabled: true },
|
||||
{ tier: 'DIAMOND', minUserLevel: 30, minActiveDays: 30, minBadges: 2, minBadgeStage: 3, minContributedPop: 1_000, enabled: true },
|
||||
] });
|
||||
await db.doc(`fandoms/${b}/members/${uid}`).update({ contributedPop: 1_000 });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
expect(await refreshPassport(uid, b)).toBe('DIAMOND');
|
||||
const pa = await db.doc(`users/${uid}/passports/${a}`).get();
|
||||
expect(pa.get('tier')).toBe('GOLD');
|
||||
expect(pa.get('memberNo')).toBe(7);
|
||||
expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39);
|
||||
// users 문서에는 단일 Tier 를 두지 않는다.
|
||||
expect((await db.doc(`users/${uid}`).get()).get('passportTier')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('탈퇴 후에도 Passport 는 남고 강등하지 않으며 재가입하면 이어 쓴다', async () => {
|
||||
const db = getFirestore();
|
||||
await awardActivityBadges(uid, { health: 10 });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
await db.doc(`fandoms/${a}/members/${uid}`).delete();
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
await db.doc(`users/${uid}`).update({ userLevel: 1 });
|
||||
await db.doc(`fandoms/${a}/members/${uid}`).set({ uid, memberNo: 99, contributedPop: 0, joinedAt: Timestamp.now() });
|
||||
expect(await refreshPassport(uid, a)).toBe('GOLD');
|
||||
const pa = await db.doc(`users/${uid}/passports/${a}`).get();
|
||||
// 첫 가입 순번 · 시각을 그대로 둔다 — 새 Passport 로 초기화하지 않는다.
|
||||
expect(pa.get('memberNo')).toBe(7);
|
||||
expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39);
|
||||
});
|
||||
|
||||
it('인정 활동은 FPI 축별 활동 카운터를 올린다', async () => {
|
||||
const db = getFirestore();
|
||||
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
|
||||
growthSource: 'quest_reward', idempotencyKey: 'passport:attend' });
|
||||
// 지급만으로는 FPI 활동이 아니다 — 카운터는 FPI 이벤트를 쓰는 경로가 올린다.
|
||||
const { writeFpiEvent } = await import('../src/ledger/fpi.js');
|
||||
await db.runTransaction(async (tx) => {
|
||||
const user = await tx.get(db.doc(`users/${uid}`));
|
||||
writeFpiEvent(tx, { uid, fandomId: a, axis: 'health', units: 2, activity: 'steps',
|
||||
sourceId: 'passport-s1', occurredAt: new Date() }, user);
|
||||
});
|
||||
expect((await db.doc(`users/${uid}`).get()).get('activityCounts')).toEqual({ health: 2 });
|
||||
});
|
||||
|
||||
it('진화 기념 Emblem 은 그 주기에 성장 기여한 사용자에게만, 참여자 알림과 함께', async () => {
|
||||
const db = getFirestore();
|
||||
const since = Timestamp.fromMillis(Date.now() - DAY);
|
||||
await db.doc(`users/${uid}/growthEvents/g1`).set({
|
||||
fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.now(),
|
||||
});
|
||||
// 주기 이전 기여 · 다른 팬덤 기여는 제외.
|
||||
await db.doc(`users/${other}/growthEvents/g0`).set({
|
||||
fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.fromMillis(Date.now() - 3 * DAY),
|
||||
});
|
||||
await db.doc(`users/${other}/growthEvents/g2`).set({
|
||||
fandomIdAtPayout: b, amount: 10, reversalOf: null, createdAt: Timestamp.now(),
|
||||
});
|
||||
const uids = await awardEvolutionEmblems({
|
||||
fandomId: a, name: '테스트', fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000),
|
||||
});
|
||||
expect(uids).toEqual([uid]);
|
||||
const emblem = await db.doc(`users/${uid}/badges/evolution_${a}_2`).get();
|
||||
expect(emblem.get('kind')).toBe('evolution');
|
||||
expect(emblem.get('toStage')).toBe(2);
|
||||
expect((await db.doc(`users/${other}/badges/evolution_${a}_2`).get()).exists).toBe(false);
|
||||
const note = await db.doc(`users/${uid}/notifications/evolution_${a}_2`).get();
|
||||
expect(note.get('body')).toContain('당신도 이 진화에 함께했습니다');
|
||||
// 재전달에도 한 번.
|
||||
await awardEvolutionEmblems({
|
||||
fandomId: a, fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000),
|
||||
});
|
||||
expect((await db.collection(`users/${uid}/badges`).get()).size).toBe(1);
|
||||
});
|
||||
@@ -76,3 +76,29 @@ it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른
|
||||
await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow();
|
||||
await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('내 기록 첫 장에 PASSPORT Stamp(진화 Emblem · 최초 100명 · Eclipse 성공 참여)를 함께 준다', async () => {
|
||||
const db = getFirestore();
|
||||
const { recordEclipseSuccess } = await import('../src/fandom/fandom.js');
|
||||
const round = db.doc(`fandoms/${fandomId}/eclipseRounds/r1`);
|
||||
await round.collection('participants').doc(uid).set({ qualified: true, validShakes: 60 });
|
||||
await round.collection('participants').doc('not-qualified').set({ qualified: false, validShakes: 10 });
|
||||
expect(await recordEclipseSuccess(fandomId, round)).toBe(1);
|
||||
expect(await recordEclipseSuccess(fandomId, round)).toBe(1);
|
||||
expect((await db.collection(`users/${uid}/passportHistory`).get()).size).toBe(1);
|
||||
expect((await db.doc(`users/not-qualified/passportHistory/eclipse_${fandomId}_r1`).get()).exists).toBe(false);
|
||||
await db.doc(`users/${uid}/badges/evolution_${fandomId}_2`).set({
|
||||
kind: 'evolution', fandomId, toStage: 2, awardedAt: Timestamp.fromMillis(50_000) });
|
||||
await db.doc(`users/${uid}/badges/founder100_${fandomId}`).set({
|
||||
kind: 'founder100', fandomId, memberNo: 7, awardedAt: Timestamp.fromMillis(1_000) });
|
||||
await db.doc(`users/${uid}/badges/founder100_other`).set({
|
||||
kind: 'founder100', fandomId: 'other', memberNo: 1, awardedAt: Timestamp.fromMillis(1_000) });
|
||||
const result = await call({ fandomId, scope: 'personal' });
|
||||
const kinds = result.items.map((item) => item.kind);
|
||||
expect(kinds[0]).toBe('eclipse_success');
|
||||
expect(kinds).toContain('emblem_evolution');
|
||||
expect(kinds.filter((k) => k === 'badge_founder100')).toHaveLength(1);
|
||||
// 팬덤 범위에는 섞지 않는다.
|
||||
expect((await call({ fandomId })).items.every((item) => item.kind === 'evolution')).toBe(true);
|
||||
await db.recursiveDelete(db.doc('users/not-qualified'));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { txIdOf, lotIdOf, deviceHashOf } from '../src/core/ids.js';
|
||||
import { dayKey, isPlausibleOccurredAt } from '../src/core/time.js';
|
||||
import { DEFAULT_POLICY, FPI_AXES, weightsAreUsable } from '../src/core/config.js';
|
||||
import { CONTRIBUTION_REASONS } from '../src/ledger/ledger.js';
|
||||
import { expForLevel, levelOf, passportTierOf, titleOf } from '../src/core/level.js';
|
||||
import { expForLevel, levelOf, titleOf } from '../src/core/level.js';
|
||||
import { badgeStageOf, BADGE_STAGE_THRESHOLDS, DEFAULT_TIER_RULES, tierOf } from '../src/account/passport.js';
|
||||
import { FPI_UNIT_FACTOR, rankWithTies } from '../src/ledger/fpi.js';
|
||||
import { fnIapRefundWebhook, fnSubscriptionWebhook } from '../src/payment/iap.js';
|
||||
import {
|
||||
@@ -223,7 +224,7 @@ describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => {
|
||||
it('신규 회원은 Lv.1 GLOW · SILVER 다', () => {
|
||||
expect(levelOf(0)).toBe(1);
|
||||
expect(titleOf(levelOf(0))).toBe('GLOW');
|
||||
expect(passportTierOf(1)).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { userLevel: 1, activeDays: 0, badgeStages: [], contributedPop: 0 })).toBe('SILVER');
|
||||
});
|
||||
|
||||
it('레벨은 EXP 요구량 경계에서 정확히 오른다', () => {
|
||||
@@ -250,13 +251,25 @@ describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => {
|
||||
expect(titleOf(levelOf(3_000))).toBe('GLOW');
|
||||
});
|
||||
|
||||
it('Passport 는 Gold Lv.30+ · Diamond Lv.70+ 이고 강등하지 않는다', () => {
|
||||
expect(passportTierOf(29)).toBe('SILVER');
|
||||
expect(passportTierOf(30)).toBe('GOLD');
|
||||
expect(passportTierOf(70)).toBe('DIAMOND');
|
||||
expect(passportTierOf(1, 'DIAMOND')).toBe('DIAMOND');
|
||||
expect(passportTierOf(40, 'DIAMOND')).toBe('DIAMOND');
|
||||
expect(passportTierOf(75, 'GOLD')).toBe('DIAMOND');
|
||||
it('Passport Tier 는 팬덤별 규칙(Level · 활동기간 · Badge · 누적기여)을 모두 채워야 오르고 강등하지 않는다', () => {
|
||||
const base = { userLevel: 30, activeDays: 30, badgeStages: [1], contributedPop: 0 };
|
||||
expect(tierOf(DEFAULT_TIER_RULES, base)).toBe('GOLD');
|
||||
// 하나라도 모자라면 그대로 — Level 만으로 오르지 않는다(상세정의 4장 · 10/3 #10).
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 29 })).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, activeDays: 29 })).toBe('SILVER');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, badgeStages: [] })).toBe('SILVER');
|
||||
const diamond = { userLevel: 70, activeDays: 180, badgeStages: [3, 3], contributedPop: 100_000 };
|
||||
expect(tierOf(DEFAULT_TIER_RULES, diamond)).toBe('DIAMOND');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...diamond, contributedPop: 99_999 })).toBe('GOLD');
|
||||
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 1 }, 'DIAMOND')).toBe('DIAMOND');
|
||||
// 운영값이 규칙을 끄면 그 Tier 로 오르지 않는다.
|
||||
expect(tierOf(DEFAULT_TIER_RULES.map((r) => ({ ...r, enabled: false })), diamond)).toBe('SILVER');
|
||||
});
|
||||
|
||||
it('활동 Badge 는 7단계 10/100/500/1,500/4,000/10,000/30,000(상세정의 4장)', () => {
|
||||
expect(BADGE_STAGE_THRESHOLDS).toEqual([10, 100, 500, 1_500, 4_000, 10_000, 30_000]);
|
||||
expect([0, 9, 10, 99, 100, 1_500, 29_999, 30_000, 1e9].map((n) => badgeStageOf(BADGE_STAGE_THRESHOLDS, n)))
|
||||
.toEqual([0, 0, 1, 1, 2, 4, 6, 7, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ void main() {
|
||||
// 레벨은 누적 기여 POP 이 아니라 User EXP 에서 나온다(C-18) — 출석 1건 × 100(후원은 EXP 대상 아님).
|
||||
expect(user.data()!['userExp'], 100);
|
||||
expect(user.data()!['userLevel'], 1);
|
||||
expect(user.data()!['passportTier'], 'SILVER');
|
||||
// Passport Tier 는 사용자 문서가 아니라 팬덤별 Passport 문서다(10/3 #10).
|
||||
expect(user.data()!['passportTier'], isNull);
|
||||
});
|
||||
|
||||
testWidgets('HOME 이 출석 보상 잔액과 행성 성장 수치를 서버 값에서 읽는다', (tester) async {
|
||||
|
||||
@@ -183,6 +183,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
insides: repos.inside,
|
||||
translation: repos.translation,
|
||||
galaxy: repos.galaxy,
|
||||
planetRepository: repos.planet,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -238,7 +238,7 @@ enum PassportTier {
|
||||
|
||||
final String label;
|
||||
|
||||
/// 서버 문자열(`users.passportTier`). 없거나 모르는 값은 기본 SILVER.
|
||||
/// 서버 문자열(`users/{uid}/passports/{fandomId}.tier`). 없거나 모르는 값은 기본 SILVER.
|
||||
static PassportTier parse(String raw) => PassportTier.values.firstWhere(
|
||||
(t) => t.label == raw.toUpperCase(),
|
||||
orElse: () => PassportTier.silver,
|
||||
@@ -250,13 +250,16 @@ enum PassportTier {
|
||||
/// *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·*
|
||||
/// *나머지 행성 티어는 티어 높은순으로 소팅*
|
||||
///
|
||||
/// 칭호는 개인 `userLevel` 에서만 나온다(C-18). 행성별로는 **기여 실적(POP)만**
|
||||
/// 보인다 — 행성 기여로 칭호를 매기면 MY 카드와 칭호가 갈린다(인계 결함).
|
||||
/// 칭호는 개인 `userLevel` 에서만 나온다(C-18). 행성별로는 **그 팬덤 Passport Tier 와 기여 실적**이
|
||||
/// 보인다 — Tier 는 사용자 × 팬덤 단위다(10/3 통합회신 #10 · 9/21 회신 5절).
|
||||
class PlanetTier {
|
||||
const PlanetTier({
|
||||
required this.fandomName,
|
||||
required this.contributedPop,
|
||||
this.isPrimary = false,
|
||||
this.fandomId = '',
|
||||
this.passportTier = PassportTier.silver,
|
||||
this.joined = true,
|
||||
});
|
||||
|
||||
final String fandomName;
|
||||
@@ -264,6 +267,39 @@ class PlanetTier {
|
||||
|
||||
/// 갤럭시에서 고른 기본 행성인가.
|
||||
final bool isPrimary;
|
||||
|
||||
final String fandomId;
|
||||
|
||||
/// 그 팬덤의 Passport Tier — `users/{uid}/passports/{fandomId}.tier`.
|
||||
final PassportTier passportTier;
|
||||
|
||||
/// 지금 가입 중인가. false 면 탈퇴한 팬덤의 보존 기록(FX §7.1 ARCHIVED).
|
||||
final bool joined;
|
||||
}
|
||||
|
||||
/// 보유 Badge 한 건 — `users/{uid}/badges/{badgeId}`(서버 판정). 상세정의 4장 · 22장 · 별첨 B.
|
||||
class MyBadge {
|
||||
const MyBadge({required this.id, required this.kind, required this.stage, this.axis = '',
|
||||
this.memberNo, this.toStage});
|
||||
|
||||
final String id;
|
||||
|
||||
/// `activity`(활동 카테고리 7단계) · `founder100`(팬덤/성운 최초 100명) · `evolution`(진화 기념).
|
||||
final String kind;
|
||||
final int stage;
|
||||
|
||||
/// 활동 Badge 카테고리(FPI 축 키).
|
||||
final String axis;
|
||||
final int? memberNo;
|
||||
final int? toStage;
|
||||
|
||||
/// 화면 표기. 축 이름은 답변서 v1.1 Q3 확정 표기(영문)라 번역하지 않는다.
|
||||
String get label => switch (kind) {
|
||||
'activity' => '${axis.toUpperCase()} · $stage/7',
|
||||
'founder100' => 'FIRST 100 · #${(memberNo ?? 0).toString().padLeft(3, '0')}',
|
||||
'evolution' => 'EVOLUTION · STAGE ${toStage ?? stage}',
|
||||
_ => id,
|
||||
};
|
||||
}
|
||||
|
||||
/// MY 첫 화면이 쓰는 값.
|
||||
@@ -279,6 +315,7 @@ class MyProfile {
|
||||
required this.steps,
|
||||
required this.completedQuests,
|
||||
required this.planetTiers,
|
||||
this.badges = const [],
|
||||
this.photoLabel,
|
||||
this.photoUrl,
|
||||
this.originNo,
|
||||
@@ -296,7 +333,7 @@ class MyProfile {
|
||||
/// 레벨 — `users.userLevel`. **서버가 준 값**이며 앱이 다시 매기지 않는다.
|
||||
final int userLevel;
|
||||
|
||||
/// Passport Tier — `users.passportTier`(B-65).
|
||||
/// 대표 팬덤의 Passport Tier(B-65 · 10/3 #10 사용자 × 팬덤). 팬덤별 값은 [planetTiers].
|
||||
final PassportTier passportTier;
|
||||
|
||||
/// 보유 POP(활동).
|
||||
@@ -308,6 +345,9 @@ class MyProfile {
|
||||
|
||||
final List<PlanetTier> planetTiers;
|
||||
|
||||
/// 보유 Badge — 별첨 1 ②-5 「등급·배지 부여·프로필 표시」.
|
||||
final List<MyBadge> badges;
|
||||
|
||||
/// 프로필 사진. 자산이 아직 없어 라벨로 둔다.
|
||||
final String? photoLabel;
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps)
|
||||
/// - 완료 퀘스트 `users/{uid}/verifiedCompletions` 표식 수(무효 제외)
|
||||
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
|
||||
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
|
||||
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
|
||||
/// - 레벨 `users/{uid}.{userExp, userLevel}`(C-18). 필드가 없는 기존 사용자는 Lv.1.
|
||||
/// - Passport `users/{uid}/passports/{fandomId}.tier` — **팬덤별**(10/3 #10 · 9/21 5절). 없으면 SILVER.
|
||||
/// 탈퇴한 팬덤의 Passport 도 지우지 않고 보인다(ARCHIVED).
|
||||
/// - Badge `users/{uid}/badges`(상세정의 4장 · 별첨 1 ②-5)
|
||||
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
|
||||
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
|
||||
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
|
||||
@@ -64,22 +66,47 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
final user = (await userF).data() ?? const <String, dynamic>{};
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
|
||||
final fandomId = user.strOr('fandomId');
|
||||
var tiers = const <PlanetTier>[];
|
||||
if (fandomId.isNotEmpty) {
|
||||
final fandomF = db.doc('fandoms/$fandomId').get();
|
||||
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
|
||||
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
|
||||
final member = (await memberF).data() ?? const <String, dynamic>{};
|
||||
tiers = [
|
||||
PlanetTier(
|
||||
fandomName: fandom.strOr('name', fandomId),
|
||||
// 팬덤별 누적 기여 POP. `fn-pop-spend` 가 기여 차감 때 함께 올린다.
|
||||
contributedPop: member.intOr('contributedPop'),
|
||||
isPrimary: true,
|
||||
final primaryId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
final joinedIds = (user['fandomIds'] as List?)?.whereType<String>().toSet() ?? <String>{};
|
||||
if (primaryId.isNotEmpty) joinedIds.add(primaryId);
|
||||
// 규칙 배포 전 서버에서는 하위 컬렉션 읽기가 거부된다 — MY 전체를 떨어뜨리지 않는다.
|
||||
Future<List<QueryDocumentSnapshot<Map<String, dynamic>>>> docsOf(String path) => db
|
||||
.collection(path)
|
||||
.get()
|
||||
.then((q) => q.docs, onError: (Object _) => <QueryDocumentSnapshot<Map<String, dynamic>>>[]);
|
||||
final passportsF = docsOf('users/$uid/passports');
|
||||
final badgesF = docsOf('users/$uid/badges');
|
||||
final passports = {for (final d in await passportsF) d.id: d.data()};
|
||||
final ids = {...joinedIds, ...passports.keys};
|
||||
final tiers = await Future.wait(ids.map((id) async {
|
||||
final joined = joinedIds.contains(id);
|
||||
final fandom = (await db.doc('fandoms/$id').get()).data() ?? const <String, dynamic>{};
|
||||
final member = joined
|
||||
? (await db.doc('fandoms/$id/members/$uid').get()).data() ?? const <String, dynamic>{}
|
||||
: const <String, dynamic>{};
|
||||
final passport = passports[id] ?? const <String, dynamic>{};
|
||||
return PlanetTier(
|
||||
fandomId: id,
|
||||
fandomName: fandom.strOr('name', id),
|
||||
// 팬덤별 누적 기여 POP. 가입 중이면 멤버 문서(`fn-pop-spend` 가 올림), 탈퇴했으면 Passport 보존값.
|
||||
contributedPop: joined ? member.intOr('contributedPop') : passport.intOr('contributedPop'),
|
||||
isPrimary: id == primaryId,
|
||||
passportTier: PassportTier.parse(passport.strOr('tier')),
|
||||
joined: joined,
|
||||
);
|
||||
}));
|
||||
final primaryTier = tiers.where((t) => t.isPrimary).firstOrNull?.passportTier ?? PassportTier.silver;
|
||||
final badges = [
|
||||
for (final d in await badgesF)
|
||||
MyBadge(
|
||||
id: d.id,
|
||||
kind: d.data().strOr('kind'),
|
||||
stage: d.data().intOr('stage'),
|
||||
axis: d.data().strOr('axis'),
|
||||
memberNo: d.data()['memberNo'] is num ? (d.data()['memberNo'] as num).toInt() : null,
|
||||
toStage: d.data()['toStage'] is num ? (d.data()['toStage'] as num).toInt() : null,
|
||||
),
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
// 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다.
|
||||
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
|
||||
@@ -95,11 +122,12 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
contributedPop: user.intOr('totalContributedPop'),
|
||||
userExp: user.intOr('userExp'),
|
||||
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
|
||||
passportTier: PassportTier.parse(user.strOr('passportTier')),
|
||||
passportTier: primaryTier,
|
||||
balancePop: wallet.intOr('activityBalance'),
|
||||
steps: user.intOr('steps'),
|
||||
completedQuests: await completedF,
|
||||
planetTiers: tiers,
|
||||
badges: badges,
|
||||
photoUrl: photoUrl.isEmpty ? null : photoUrl,
|
||||
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import '../galaxy/galaxy_data.dart';
|
||||
import '../galaxy/galaxy_home_screen.dart' show GalaxyHomeScreen;
|
||||
import '../inside/inside_data.dart' show InsideRepository;
|
||||
import '../planet/planet_data.dart' show PlanetRepositoryFactory;
|
||||
import '../translation/translation_data.dart' show TranslationRepository;
|
||||
import 'my_data.dart';
|
||||
import 'my_lists_screen.dart';
|
||||
@@ -36,10 +37,14 @@ class MyScreen extends StatefulWidget {
|
||||
this.insides,
|
||||
this.translation,
|
||||
this.galaxy = const SampleGalaxyRepository(),
|
||||
this.planetRepository,
|
||||
});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 팬덤별 Passport History(9/21 5절) — 티어 상세에서 행성을 누르면 그 팬덤의 기록을 연다.
|
||||
final PlanetRepositoryFactory? planetRepository;
|
||||
|
||||
/// 「행성/성운」 칸 — GALAXY 「내 행성」과 같은 가입 목록(`users/{uid}/memberships`)을 읽는다.
|
||||
final GalaxyRepository galaxy;
|
||||
|
||||
@@ -196,7 +201,11 @@ class _MyScreenState extends State<MyScreen> {
|
||||
profile: profile,
|
||||
onInfo: () => _push(const TierInfoScreen()),
|
||||
onDetail: () => _push(
|
||||
TierDetailScreen(tiers: profile.planetTiers),
|
||||
TierDetailScreen(
|
||||
tiers: profile.planetTiers,
|
||||
badges: profile.badges,
|
||||
planetRepository: widget.planetRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../planet/planet_data.dart' show PlanetRepositoryFactory;
|
||||
import '../planet/planet_history_screen.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -98,17 +100,32 @@ class TierInfoScreen extends StatelessWidget {
|
||||
/// 명세 `549:22206` — *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·*
|
||||
/// *나머지 행성 티어는 티어 높은순으로 소팅*
|
||||
///
|
||||
/// 칭호는 개인 레벨에서만 나오므로(C-18) 행성별로는 **기여 실적(POP)만** 보인다.
|
||||
/// 칭호는 개인 레벨에서만 나오므로(C-18) 행성별로는 **그 팬덤 Passport Tier 와 기여 실적**을 보인다
|
||||
/// (10/3 #10 사용자 × 팬덤). 탈퇴한 팬덤도 기록을 지우지 않고 아래에 둔다(9/21 5절 · FX §7.1 ARCHIVED).
|
||||
/// 아래 BADGE 는 별첨 1 ②-5 「배지 부여·프로필 표시」 — 앱 Figma 에 Badge 화면이 없어 이 화면 끝에 둔다.
|
||||
class TierDetailScreen extends StatelessWidget {
|
||||
const TierDetailScreen({super.key, required this.tiers});
|
||||
const TierDetailScreen({
|
||||
super.key,
|
||||
required this.tiers,
|
||||
this.badges = const [],
|
||||
this.planetRepository,
|
||||
});
|
||||
|
||||
final List<PlanetTier> tiers;
|
||||
final List<MyBadge> badges;
|
||||
|
||||
/// 기본 행성이 맨 앞, 나머지는 기여 POP 높은 순.
|
||||
/// 행성 카드를 누르면 그 팬덤의 Passport History(팬덤 역사 · 내 기록)를 연다. null 이면 열지 않는다.
|
||||
final PlanetRepositoryFactory? planetRepository;
|
||||
|
||||
/// 기본 행성이 맨 앞, 나머지는 가입 중 → 탈퇴 순, 같은 구분 안에서는 Tier · 기여 POP 높은 순.
|
||||
List<PlanetTier> get ordered {
|
||||
final primary = tiers.where((p) => p.isPrimary).toList();
|
||||
final rest = tiers.where((p) => !p.isPrimary).toList()
|
||||
..sort((a, b) => b.contributedPop.compareTo(a.contributedPop));
|
||||
..sort((a, b) {
|
||||
if (a.joined != b.joined) return a.joined ? -1 : 1;
|
||||
final byTier = b.passportTier.index.compareTo(a.passportTier.index);
|
||||
return byTier != 0 ? byTier : b.contributedPop.compareTo(a.contributedPop);
|
||||
});
|
||||
return [...primary, ...rest];
|
||||
}
|
||||
|
||||
@@ -128,9 +145,21 @@ class TierDetailScreen extends StatelessWidget {
|
||||
children: [
|
||||
for (final (i, planet) in list.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 16),
|
||||
_PlanetCard(
|
||||
planet: planet,
|
||||
progress: top == 0 ? 0 : planet.contributedPop / top,
|
||||
InkWell(
|
||||
onTap: planetRepository == null || planet.fandomId.isEmpty
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetHistoryScreen(
|
||||
repository: planetRepository!(planet.fandomId),
|
||||
fandomName: planet.fandomName,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _PlanetCard(
|
||||
planet: planet,
|
||||
progress: top == 0 ? 0 : planet.contributedPop / top,
|
||||
),
|
||||
),
|
||||
if (i == 0 && planet.isPrimary && list.length > 1) ...[
|
||||
const SizedBox(height: 16),
|
||||
@@ -140,6 +169,19 @@ class TierDetailScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
],
|
||||
if (badges.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text('BADGE', style: myText(16, FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final badge in badges)
|
||||
Chip(label: Text(badge.label, style: myText(12, FontWeight.w500))),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -194,6 +236,15 @@ class _PlanetCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'PASSPORT ${planet.passportTier.label}',
|
||||
style: myText(
|
||||
14,
|
||||
FontWeight.w500,
|
||||
color: planet.joined ? AppColors.onCosmic : const Color(0xFF84868E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MyProgressBar(value: progress),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
|
||||
@@ -314,6 +314,11 @@ enum PlanetHistoryKind {
|
||||
questReward,
|
||||
companyAdBonus,
|
||||
funding,
|
||||
|
||||
/// PASSPORT Stamp — Eclipse 성공 참여(FX §5.2) · 진화 기념 Emblem(상세정의 22장) · 최초 100명 Badge(별첨 B).
|
||||
eclipseSuccess,
|
||||
emblemEvolution,
|
||||
founderBadge,
|
||||
}
|
||||
|
||||
class PlanetHistoryEntry {
|
||||
@@ -325,6 +330,7 @@ class PlanetHistoryEntry {
|
||||
this.toStage,
|
||||
this.participationConfirmed = false,
|
||||
this.amount,
|
||||
this.memberNo,
|
||||
});
|
||||
final String id;
|
||||
final PlanetHistoryKind kind;
|
||||
@@ -333,6 +339,9 @@ class PlanetHistoryEntry {
|
||||
final int? toStage;
|
||||
final bool participationConfirmed;
|
||||
final int? amount;
|
||||
|
||||
/// 최초 100명 Badge 의 순번.
|
||||
final int? memberNo;
|
||||
}
|
||||
|
||||
class PlanetHistoryPage {
|
||||
|
||||
@@ -95,6 +95,9 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
||||
PlanetHistoryKind.questReward => Icons.task_alt,
|
||||
PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline,
|
||||
PlanetHistoryKind.funding => Icons.favorite_border,
|
||||
PlanetHistoryKind.eclipseSuccess => Icons.shield_outlined,
|
||||
PlanetHistoryKind.emblemEvolution => Icons.military_tech_outlined,
|
||||
PlanetHistoryKind.founderBadge => Icons.workspace_premium_outlined,
|
||||
}),
|
||||
title: Text(switch (item.kind) {
|
||||
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
|
||||
@@ -107,6 +110,12 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
||||
PlanetHistoryKind.questReward => context.l10n.historyQuest,
|
||||
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
|
||||
PlanetHistoryKind.funding => context.l10n.historyFunding,
|
||||
PlanetHistoryKind.eclipseSuccess =>
|
||||
context.l10n.historyEclipseSuccess,
|
||||
PlanetHistoryKind.emblemEvolution =>
|
||||
'${context.l10n.historyEmblemEvolution} · STAGE ${item.toStage ?? ''}',
|
||||
PlanetHistoryKind.founderBadge =>
|
||||
'${context.l10n.historyFounderBadge} · #${(item.memberNo ?? 0).toString().padLeft(3, '0')}',
|
||||
}),
|
||||
subtitle: Text(
|
||||
[
|
||||
|
||||
@@ -59,6 +59,9 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
'quest_reward' => PlanetHistoryKind.questReward,
|
||||
'company_ad_bonus' => PlanetHistoryKind.companyAdBonus,
|
||||
'funding' => PlanetHistoryKind.funding,
|
||||
'eclipse_success' => PlanetHistoryKind.eclipseSuccess,
|
||||
'emblem_evolution' => PlanetHistoryKind.emblemEvolution,
|
||||
'badge_founder100' => PlanetHistoryKind.founderBadge,
|
||||
_ => null,
|
||||
};
|
||||
final date = row['occurredAt'] is String
|
||||
@@ -74,7 +77,12 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
PlanetHistoryKind.companyAdBonus,
|
||||
PlanetHistoryKind.funding,
|
||||
].contains(kind);
|
||||
if (activity != personal ||
|
||||
final stamp = [
|
||||
PlanetHistoryKind.eclipseSuccess,
|
||||
PlanetHistoryKind.emblemEvolution,
|
||||
PlanetHistoryKind.founderBadge,
|
||||
].contains(kind);
|
||||
if ((activity || stamp) != personal ||
|
||||
(activity && (amount is! int || amount <= 0)) ||
|
||||
kind == null ||
|
||||
date == null ||
|
||||
@@ -95,7 +103,12 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
occurredAt: date,
|
||||
amount: activity ? amount as int : null,
|
||||
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
|
||||
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
|
||||
toStage: kind == PlanetHistoryKind.evolution
|
||||
? to as int
|
||||
: kind == PlanetHistoryKind.emblemEvolution && to is int
|
||||
? to
|
||||
: null,
|
||||
memberNo: row['memberNo'] is int ? row['memberNo'] as int : null,
|
||||
participationConfirmed:
|
||||
kind == PlanetHistoryKind.evolution &&
|
||||
row['participationConfirmed'] == true,
|
||||
|
||||
@@ -528,6 +528,9 @@
|
||||
"historyQuest": "Quest reward",
|
||||
"historyAdBonus": "Ad bonus reward",
|
||||
"historyFunding": "Campaign contribution",
|
||||
"historyEclipseSuccess": "Joined a successful eclipse defense",
|
||||
"historyEmblemEvolution": "Evolution emblem",
|
||||
"historyFounderBadge": "First 100 commemorative badge",
|
||||
"historyGrowthPop": "{amount} POP added to growth",
|
||||
"historyJoined": "You were part of this evolution",
|
||||
"historyLoadFailed": "Couldn't load history.",
|
||||
|
||||
@@ -528,6 +528,9 @@
|
||||
"historyQuest": "Recompensa de Quest",
|
||||
"historyAdBonus": "Recompensa extra por anuncio",
|
||||
"historyFunding": "Contribución a Campaign",
|
||||
"historyEclipseSuccess": "Participaste en una defensa exitosa del eclipse",
|
||||
"historyEmblemEvolution": "Emblema de evolución",
|
||||
"historyFounderBadge": "Insignia conmemorativa de los primeros 100",
|
||||
"historyGrowthPop": "{amount} POP sumados al crecimiento",
|
||||
"historyJoined": "Participaste en esta evolución",
|
||||
"historyLoadFailed": "No se pudo cargar el historial.",
|
||||
|
||||
@@ -528,6 +528,9 @@
|
||||
"historyQuest": "Quest報酬",
|
||||
"historyAdBonus": "広告追加報酬",
|
||||
"historyFunding": "Campaign貢献",
|
||||
"historyEclipseSuccess": "イクリプス防御成功に参加",
|
||||
"historyEmblemEvolution": "進化記念エンブレム",
|
||||
"historyFounderBadge": "最初の100人記念バッジ",
|
||||
"historyGrowthPop": "成長に{amount} POP反映",
|
||||
"historyJoined": "この進化に参加しました",
|
||||
"historyLoadFailed": "記録を読み込めませんでした。",
|
||||
|
||||
@@ -1032,6 +1032,9 @@
|
||||
"historyQuest": "Quest 보상",
|
||||
"historyAdBonus": "광고 추가 보상",
|
||||
"historyFunding": "Campaign 기여",
|
||||
"historyEclipseSuccess": "이클립스 방어 성공 참여",
|
||||
"historyEmblemEvolution": "진화 기념 Emblem",
|
||||
"historyFounderBadge": "최초 100명 기념 Badge",
|
||||
"historyGrowthPop": "성장에 {amount} POP 반영",
|
||||
"@historyGrowthPop": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -3272,6 +3272,24 @@ abstract class AppLocalizations {
|
||||
/// **'Campaign 기여'**
|
||||
String get historyFunding;
|
||||
|
||||
/// No description provided for @historyEclipseSuccess.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'이클립스 방어 성공 참여'**
|
||||
String get historyEclipseSuccess;
|
||||
|
||||
/// No description provided for @historyEmblemEvolution.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'진화 기념 Emblem'**
|
||||
String get historyEmblemEvolution;
|
||||
|
||||
/// No description provided for @historyFounderBadge.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'최초 100명 기념 Badge'**
|
||||
String get historyFounderBadge;
|
||||
|
||||
/// No description provided for @historyGrowthPop.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
|
||||
@@ -1814,6 +1814,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get historyFunding => 'Campaign contribution';
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess => 'Joined a successful eclipse defense';
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => 'Evolution emblem';
|
||||
|
||||
@override
|
||||
String get historyFounderBadge => 'First 100 commemorative badge';
|
||||
|
||||
@override
|
||||
String historyGrowthPop(String amount) {
|
||||
return '$amount POP added to growth';
|
||||
|
||||
@@ -1823,6 +1823,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get historyFunding => 'Contribución a Campaign';
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess =>
|
||||
'Participaste en una defensa exitosa del eclipse';
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => 'Emblema de evolución';
|
||||
|
||||
@override
|
||||
String get historyFounderBadge =>
|
||||
'Insignia conmemorativa de los primeros 100';
|
||||
|
||||
@override
|
||||
String historyGrowthPop(String amount) {
|
||||
return '$amount POP sumados al crecimiento';
|
||||
|
||||
@@ -1754,6 +1754,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get historyFunding => 'Campaign貢献';
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess => 'イクリプス防御成功に参加';
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => '進化記念エンブレム';
|
||||
|
||||
@override
|
||||
String get historyFounderBadge => '最初の100人記念バッジ';
|
||||
|
||||
@override
|
||||
String historyGrowthPop(String amount) {
|
||||
return '成長に$amount POP反映';
|
||||
|
||||
@@ -1755,6 +1755,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get historyFunding => 'Campaign 기여';
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess => '이클립스 방어 성공 참여';
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => '진화 기념 Emblem';
|
||||
|
||||
@override
|
||||
String get historyFounderBadge => '최초 100명 기념 Badge';
|
||||
|
||||
@override
|
||||
String historyGrowthPop(String amount) {
|
||||
return '성장에 $amount POP 반영';
|
||||
|
||||
@@ -1753,6 +1753,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get historyFunding => 'Campaign 贡献';
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess => '参与日食防御成功';
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => '进化纪念徽章';
|
||||
|
||||
@override
|
||||
String get historyFounderBadge => '前100名纪念徽章';
|
||||
|
||||
@override
|
||||
String historyGrowthPop(String amount) {
|
||||
return '$amount POP 计入成长';
|
||||
|
||||
@@ -528,6 +528,9 @@
|
||||
"historyQuest": "Quest 奖励",
|
||||
"historyAdBonus": "广告额外奖励",
|
||||
"historyFunding": "Campaign 贡献",
|
||||
"historyEclipseSuccess": "参与日食防御成功",
|
||||
"historyEmblemEvolution": "进化纪念徽章",
|
||||
"historyFounderBadge": "前100名纪念徽章",
|
||||
"historyGrowthPop": "{amount} POP 计入成长",
|
||||
"historyJoined": "你参与了这次进化",
|
||||
"historyLoadFailed": "无法加载记录。",
|
||||
|
||||
@@ -868,12 +868,21 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get historyAdBonus => _remote['historyAdBonus'] ?? _base.historyAdBonus;
|
||||
|
||||
@override
|
||||
String get historyEclipseSuccess => _remote['historyEclipseSuccess'] ?? _base.historyEclipseSuccess;
|
||||
|
||||
@override
|
||||
String get historyEmblemEvolution => _remote['historyEmblemEvolution'] ?? _base.historyEmblemEvolution;
|
||||
|
||||
@override
|
||||
String historyEvolution(String from, String to) => _fill('historyEvolution', {'from': from, 'to': to}) ?? _base.historyEvolution(from, to);
|
||||
|
||||
@override
|
||||
String get historyFandomEmpty => _remote['historyFandomEmpty'] ?? _base.historyFandomEmpty;
|
||||
|
||||
@override
|
||||
String get historyFounderBadge => _remote['historyFounderBadge'] ?? _base.historyFounderBadge;
|
||||
|
||||
@override
|
||||
String get historyFunding => _remote['historyFunding'] ?? _base.historyFunding;
|
||||
|
||||
|
||||
@@ -296,6 +296,29 @@ void main() {
|
||||
'작은팬덤',
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('팬덤별 Passport Tier · 탈퇴 팬덤 보존 · Badge 를 보인다 (10/3 #10 · 별첨 1 ②-5)', (tester) async {
|
||||
const tiers = [
|
||||
PlanetTier(fandomName: '떠난팬덤', contributedPop: 900, passportTier: PassportTier.diamond, joined: false),
|
||||
PlanetTier(fandomName: '다른팬덤', contributedPop: 100, passportTier: PassportTier.gold),
|
||||
PlanetTier(fandomName: '내행성', contributedPop: 500, isPrimary: true),
|
||||
];
|
||||
const badges = [
|
||||
MyBadge(id: 'activity_health', kind: 'activity', stage: 2, axis: 'health'),
|
||||
MyBadge(id: 'founder100_x', kind: 'founder100', stage: 1, memberNo: 7),
|
||||
MyBadge(id: 'evolution_x_2', kind: 'evolution', stage: 1, toStage: 2),
|
||||
];
|
||||
const screen = TierDetailScreen(tiers: tiers, badges: badges);
|
||||
expect(screen.ordered.map((p) => p.fandomName).toList(), ['내행성', '다른팬덤', '떠난팬덤']);
|
||||
await pumpWide(tester, screen);
|
||||
expect(find.text('PASSPORT SILVER'), findsOneWidget);
|
||||
expect(find.text('PASSPORT GOLD'), findsOneWidget);
|
||||
expect(find.text('PASSPORT DIAMOND'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('BADGE'), 200);
|
||||
expect(find.text('HEALTH · 2/7'), findsOneWidget);
|
||||
expect(find.text('FIRST 100 · #007'), findsOneWidget);
|
||||
expect(find.text('EVOLUTION · STAGE 2'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('프로필 수정 (F6 · 374:16164 · 374:15479)', () {
|
||||
|
||||
@@ -137,6 +137,19 @@ void main() {
|
||||
expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'});
|
||||
expect(personal.items.single.amount, 37);
|
||||
expect(personal.items.single.participationConfirmed, isFalse);
|
||||
// PASSPORT Stamp 는 내 기록에서만 받는다(FX §5.2 · §7.2).
|
||||
backend.response = {'items': [
|
||||
{'id': 'e', 'kind': 'eclipse_success', 'occurredAt': '2026-09-23T00:00:00Z'},
|
||||
{'id': 'v', 'kind': 'emblem_evolution', 'occurredAt': '2026-09-22T00:00:00Z', 'toStage': 3},
|
||||
{'id': 'f', 'kind': 'badge_founder100', 'occurredAt': '2026-09-21T00:00:00Z', 'memberNo': 7},
|
||||
], 'nextCursor': null};
|
||||
final stamps = await repository.history(personal: true);
|
||||
expect(stamps.items.map((i) => i.kind), [
|
||||
PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge,
|
||||
]);
|
||||
expect(stamps.items[1].toStage, 3);
|
||||
expect(stamps.items[2].memberNo, 7);
|
||||
await expectLater(repository.history(), throwsStateError);
|
||||
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user