From cc0f2fb292687c5fdd08e670a3dba5fd969365c9 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 22:09:10 +0900 Subject: [PATCH] =?UTF-8?q?Passport=20Tier=20=ED=8C=AC=EB=8D=A4=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=ED=99=9C=EB=8F=99=20Badge=20?= =?UTF-8?q?=C2=B7=20=EC=A7=84=ED=99=94=20Emblem=20=C2=B7=20Eclipse=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/firestore.rules | 13 + .../functions/src/account/passport.ts | 253 ++++++++++++++++++ app/firebase/functions/src/core/level.ts | 13 +- .../functions/src/fandom/evolution.ts | 63 ++++- app/firebase/functions/src/fandom/fandom.ts | 28 +- app/firebase/functions/src/fandom/history.ts | 29 +- app/firebase/functions/src/index.ts | 1 + app/firebase/functions/src/ledger/fpi.ts | 7 +- app/firebase/functions/src/ledger/ledger.ts | 16 +- app/firebase/functions/test/ledger.test.ts | 5 - app/firebase/functions/test/passport.test.ts | 121 +++++++++ .../functions/test/planet_history.test.ts | 26 ++ app/firebase/functions/test/pure.test.ts | 31 ++- .../pop_loop_emulator_test.dart | 3 +- app/lib/core/router/app_router.dart | 1 + app/lib/features/my/my_data.dart | 48 +++- .../features/my/my_repository_firestore.dart | 64 +++-- app/lib/features/my/my_screen.dart | 11 +- app/lib/features/my/tier_screens.dart | 65 ++++- app/lib/features/planet/planet_data.dart | 9 + .../planet/planet_history_screen.dart | 9 + .../planet/planet_repository_firestore.dart | 17 +- app/lib/l10n/app_en.arb | 3 + app/lib/l10n/app_es.arb | 3 + app/lib/l10n/app_ja.arb | 3 + app/lib/l10n/app_ko.arb | 3 + app/lib/l10n/app_localizations.dart | 18 ++ app/lib/l10n/app_localizations_en.dart | 9 + app/lib/l10n/app_localizations_es.dart | 11 + app/lib/l10n/app_localizations_ja.dart | 9 + app/lib/l10n/app_localizations_ko.dart | 9 + app/lib/l10n/app_localizations_zh.dart | 9 + app/lib/l10n/app_zh.arb | 3 + app/lib/l10n/remote_app_localizations.dart | 9 + app/test/f6_screens_test.dart | 23 ++ app/test/planet_history_test.dart | 13 + 36 files changed, 877 insertions(+), 81 deletions(-) create mode 100644 app/firebase/functions/src/account/passport.ts create mode 100644 app/firebase/functions/test/passport.test.ts diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 089673d..1ff2fcf 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -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} { diff --git a/app/firebase/functions/src/account/passport.ts b/app/firebase/functions/src/account/passport.ts new file mode 100644 index 0000000..ac9af4d --- /dev/null +++ b/app/firebase/functions/src/account/passport.ts @@ -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; + 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 { + 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 { + 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, +): Promise { + 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): Promise { + 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 { + 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 }); +}); diff --git a/app/firebase/functions/src/core/level.ts b/app/firebase/functions/src/core/level.ts index dc45444..72e10c3 100644 --- a/app/firebase/functions/src/core/level.ts +++ b/app/firebase/functions/src/core/level.ts @@ -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); } diff --git a/app/firebase/functions/src/fandom/evolution.ts b/app/firebase/functions/src/fandom/evolution.ts index 2681138..eb2ea0a 100644 --- a/app/firebase/functions/src/fandom/evolution.ts +++ b/app/firebase/functions/src/fandom/evolution.ts @@ -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 { + 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 fandomId: ref.id, category: 'fandomEvent', title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`, - body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.', + // 토픽은 팬덤 전원이라 참여 여부를 단정하지 않는다. 참여자 문구는 알림함(awardEvolutionEmblems). + body: '팬덤이 함께 만든 진화입니다. 행성에서 확인해 보세요.', data: { kind: 'evolution', toStage: String(promotion.toStage) }, }); } diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index 9aec245..fbbae44 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -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 { + 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 집계」 확정). * diff --git a/app/firebase/functions/src/fandom/history.ts b/app/firebase/functions/src/fandom/history.ts index 7fbe781..0260803 100644 --- a/app/firebase/functions/src/fandom/history.ts +++ b/app/firebase/functions/src/fandom/history.ts @@ -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 }; }); diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index becf774..5012e66 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -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'; diff --git a/app/firebase/functions/src/ledger/fpi.ts b/app/firebase/functions/src/ledger/fpi.ts index ab6a2f3..85af61c 100644 --- a/app/firebase/functions/src/ledger/fpi.ts +++ b/app/firebase/functions/src/ledger/fpi.ts @@ -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 }); } diff --git a/app/firebase/functions/src/ledger/ledger.ts b/app/firebase/functions/src/ledger/ledger.ts index 7ab6259..cb15c1a 100644 --- a/app/firebase/functions/src/ledger/ledger.ts +++ b/app/firebase/functions/src/ledger/ledger.ts @@ -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 { 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 { 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(), diff --git a/app/firebase/functions/test/ledger.test.ts b/app/firebase/functions/test/ledger.test.ts index 84b5f4e..d21db29 100644 --- a/app/firebase/functions/test/ledger.test.ts +++ b/app/firebase/functions/test/ledger.test.ts @@ -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 () => { diff --git a/app/firebase/functions/test/passport.test.ts b/app/firebase/functions/test/passport.test.ts new file mode 100644 index 0000000..29c7058 --- /dev/null +++ b/app/firebase/functions/test/passport.test.ts @@ -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); +}); diff --git a/app/firebase/functions/test/planet_history.test.ts b/app/firebase/functions/test/planet_history.test.ts index 9385320..6284e70 100644 --- a/app/firebase/functions/test/planet_history.test.ts +++ b/app/firebase/functions/test/planet_history.test.ts @@ -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')); +}); diff --git a/app/firebase/functions/test/pure.test.ts b/app/firebase/functions/test/pure.test.ts index 3019435..03589b7 100644 --- a/app/firebase/functions/test/pure.test.ts +++ b/app/firebase/functions/test/pure.test.ts @@ -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]); }); }); diff --git a/app/integration_test/pop_loop_emulator_test.dart b/app/integration_test/pop_loop_emulator_test.dart index 3a56504..d2f8b7f 100644 --- a/app/integration_test/pop_loop_emulator_test.dart +++ b/app/integration_test/pop_loop_emulator_test.dart @@ -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 { diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index ad0af50..c72aeea 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -183,6 +183,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { insides: repos.inside, translation: repos.translation, galaxy: repos.galaxy, + planetRepository: repos.planet, ), ), ], diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart index 999b439..6b18082 100644 --- a/app/lib/features/my/my_data.dart +++ b/app/lib/features/my/my_data.dart @@ -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 planetTiers; + /// 보유 Badge — 별첨 1 ②-5 「등급·배지 부여·프로필 표시」. + final List badges; + /// 프로필 사진. 자산이 아직 없어 라벨로 둔다. final String? photoLabel; diff --git a/app/lib/features/my/my_repository_firestore.dart b/app/lib/features/my/my_repository_firestore.dart index 2d9d183..68185da 100644 --- a/app/lib/features/my/my_repository_firestore.dart +++ b/app/lib/features/my/my_repository_firestore.dart @@ -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 {}; final wallet = (await walletF).data() ?? const {}; - final fandomId = user.strOr('fandomId'); - var tiers = const []; - 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 {}; - final member = (await memberF).data() ?? const {}; - 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().toSet() ?? {}; + if (primaryId.isNotEmpty) joinedIds.add(primaryId); + // 규칙 배포 전 서버에서는 하위 컬렉션 읽기가 거부된다 — MY 전체를 떨어뜨리지 않는다. + Future>>> docsOf(String path) => db + .collection(path) + .get() + .then((q) => q.docs, onError: (Object _) => >>[]); + 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 {}; + final member = joined + ? (await db.doc('fandoms/$id/members/$uid').get()).data() ?? const {} + : const {}; + final passport = passports[id] ?? const {}; + 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, ); diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 3bbbf20..69f88e6 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -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 { 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), diff --git a/app/lib/features/my/tier_screens.dart b/app/lib/features/my/tier_screens.dart index f46ebeb..ba49048 100644 --- a/app/lib/features/my/tier_screens.dart +++ b/app/lib/features/my/tier_screens.dart @@ -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 tiers; + final List badges; - /// 기본 행성이 맨 앞, 나머지는 기여 POP 높은 순. + /// 행성 카드를 누르면 그 팬덤의 Passport History(팬덤 역사 · 내 기록)를 연다. null 이면 열지 않는다. + final PlanetRepositoryFactory? planetRepository; + + /// 기본 행성이 맨 앞, 나머지는 가입 중 → 탈퇴 순, 같은 구분 안에서는 Tier · 기여 POP 높은 순. List 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( + 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( diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index 30158aa..d3644c5 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -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 { diff --git a/app/lib/features/planet/planet_history_screen.dart b/app/lib/features/planet/planet_history_screen.dart index 8980381..e6a8254 100644 --- a/app/lib/features/planet/planet_history_screen.dart +++ b/app/lib/features/planet/planet_history_screen.dart @@ -95,6 +95,9 @@ class _PlanetHistoryScreenState extends State { 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 { 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( [ diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index 86c30ef..a6ced16 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -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, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 1475344..ad03a37 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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.", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 029ba6d..b4fad2e 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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.", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 7c08713..c971922 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -528,6 +528,9 @@ "historyQuest": "Quest報酬", "historyAdBonus": "広告追加報酬", "historyFunding": "Campaign貢献", + "historyEclipseSuccess": "イクリプス防御成功に参加", + "historyEmblemEvolution": "進化記念エンブレム", + "historyFounderBadge": "最初の100人記念バッジ", "historyGrowthPop": "成長に{amount} POP反映", "historyJoined": "この進化に参加しました", "historyLoadFailed": "記録を読み込めませんでした。", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 75820fb..e3feb67 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1032,6 +1032,9 @@ "historyQuest": "Quest 보상", "historyAdBonus": "광고 추가 보상", "historyFunding": "Campaign 기여", + "historyEclipseSuccess": "이클립스 방어 성공 참여", + "historyEmblemEvolution": "진화 기념 Emblem", + "historyFounderBadge": "최초 100명 기념 Badge", "historyGrowthPop": "성장에 {amount} POP 반영", "@historyGrowthPop": { "placeholders": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index d5b00b5..fa21609 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 7d661eb..18601f1 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index db683c5..59a1002 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 6e55940..8a37fbe 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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反映'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 5f9a300..d958fac 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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 반영'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 3307784..49a55cd 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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 计入成长'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index a733c9d..a70484b 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -528,6 +528,9 @@ "historyQuest": "Quest 奖励", "historyAdBonus": "广告额外奖励", "historyFunding": "Campaign 贡献", + "historyEclipseSuccess": "参与日食防御成功", + "historyEmblemEvolution": "进化纪念徽章", + "historyFounderBadge": "前100名纪念徽章", "historyGrowthPop": "{amount} POP 计入成长", "historyJoined": "你参与了这次进化", "historyLoadFailed": "无法加载记录。", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 3595b8b..bb35751 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 73d856c..3d800da 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -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)', () { diff --git a/app/test/planet_history_test.dart b/app/test/planet_history_test.dart index 9d339f9..255b3f4 100644 --- a/app/test/planet_history_test.dart +++ b/app/test/planet_history_test.dart @@ -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); });