import 'package:cloud_firestore/cloud_firestore.dart' show FieldPath, QueryDocumentSnapshot; import '../../core/data/backend.dart'; import '../planet/planet_data.dart' show rankWithTies; import 'galaxy_data.dart'; /// 갤럭시 맵의 실 서버 구현 — 화면 대조 원장 F2. /// /// 순위는 앱에서 정렬하지 않고 서버의 `fpiScore` 내림차순을 그대로 받는다. /// 클라이언트가 순위를 다시 매기면 화면마다 값이 갈린다. class FirestoreGalaxyRepository implements GalaxyRepository { FirestoreGalaxyRepository(this._backend); final Backend _backend; /// 한 화면에 띄우는 행성 수. 더 늘리면 궤도가 겹쳐 읽히지 않는다. static const int visibleCount = 12; @override Future load() async { final uid = _backend.requireUid; final db = _backend.db; final userSnap = await db.doc('users/$uid').get(); final user = userSnap.data() ?? const {}; // 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다. final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); // 「가입 또는 생성한 행성/성운」 — 최신 가입순 전부(Figma `540:19586`). 세 칸은 한 줄의 칸 수다. final membershipsF = db .collection('users/$uid/memberships') .orderBy('joinedAt', descending: true) .get(); // 오늘(KST) 성운 생성 기록 — 서버 `dayKey`(UTC+9)와 같은 키. final kst = DateTime.now().toUtc().add(const Duration(hours: 9)); final todayKey = '${kst.year.toString().padLeft(4, '0')}-${kst.month.toString().padLeft(2, '0')}-${kst.day.toString().padLeft(2, '0')}'; // 규칙 배포 전이면 읽기가 거부된다 — 그때는 버튼을 막지 않고 서버 판정(E-FDM-02)에 맡긴다. final createdTodayF = db .doc('users/$uid/nebulaCreates/$todayKey') .get() .then((d) => d.exists, onError: (_) => false); final fandomsF = db .collection('fandoms') .orderBy('fpiScore', descending: true) .limit(visibleCount) .get(); final walletF = db.doc('users/$uid/wallet/main').get(); final eventF = db .collection('galaxyEvents') .orderBy('createdAt', descending: true) .limit(1) .get(); // ZAP 단가는 운영값이다(Figma 「Zap 버튼」 = 어드민 설정). 로그인 전에도 읽을 수 // 있게 `firestore.rules` 가 `config/{id}` 를 열어 두었다. final policyF = db.doc('config/policy').get(); final fandoms = (await fandomsF).docs; final wallet = (await walletF).data() ?? const {}; final events = (await eventF).docs; final policy = (await policyF).data() ?? const {}; // 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는 // fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다. // 랭킹 모집단 밖(B-08 · B-17)은 순위를 매기기 **전에** 뺀다 — 끼면 정식 행성의 순위 // 번호가 밀린다. Stage0 성운과 서버가 `rankingEligible: false`(최근 7 일 유효 활동 없음)로 // 내린 행성이다. 이들은 맨 바깥 궤도 자리(모집단 수 + 1)에 두고 순위는 「-」로 그린다. // 필드가 아직 없는 문서(집계 배포 전)는 Stage 만으로 판정한다. bool unranked(QueryDocumentSnapshot> d) => d.data()['stage'] == 0 || d.data()['rankingEligible'] == false; final formal = fandoms.where((d) => !unranked(d)).toList(); final planets = [ for (final (doc, rank) in rankWithTies( formal, (d) => d.data()['fpiScore'] is num ? d.data()['fpiScore'] as num : 0, (d) => d.id, )) _node(doc.id, doc.data(), rank, myFandomId), for (final doc in fandoms.where(unranked)) _node(doc.id, doc.data(), formal.length + 1, myFandomId, ranked: false), ]; final byId = {for (final p in planets) p.id: p}; final membershipIds = [for (final d in (await membershipsF).docs) d.id]; // 맵(FPI 상위 12)에 없는 가입 팬덤만 따로 읽는다. final missing = await Future.wait([ for (final id in membershipIds.where((id) => !byId.containsKey(id))) db.doc('fandoms/$id').get(), ]); for (final doc in missing) { final data = doc.data(); if (data != null) { byId[doc.id] = _node( doc.id, data, formal.length + 1, myFandomId, ranked: false, ); } } return GalaxySnapshot( planets: planets, myPop: wallet.intOr('activityBalance'), zapPop: policy.intOr('zapPop', 10), event: events.isEmpty ? null : _event(events.first.data()), // Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙). joined: myFandomId.isNotEmpty, memberships: [for (final id in membershipIds) ?byId[id]], nebulaCreatedToday: await createdTodayF, joinedIds: { ...?(user['fandomIds'] as List?)?.whereType(), if (myFandomId.isNotEmpty) myFandomId, }, ); } /// 전체 행성 이름 접두 검색 — 이름 색인 `fandomNames/{소문자 이름}`(서버가 성운 생성 때 쓴다). /// 맵 상위 [visibleCount] 개 안에서만 거르던 것을 고쳤다(Figma `353:217832` 「행성 검색으로 대상 행성 탐색」). /// ponytail: 접두 일치만 — 중간 글자 일치가 필요하면 검색 색인(n-gram)을 서버에 둔다. @override Future> search(String query) async { final q = query.trim().toLowerCase(); if (q.isEmpty) return const []; final uid = _backend.requireUid; final db = _backend.db; final user = (await db.doc('users/$uid').get()).data() ?? const {}; final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); final names = await db .collection('fandomNames') .where(FieldPath.documentId, isGreaterThanOrEqualTo: q) .where(FieldPath.documentId, isLessThan: '$q\uf8ff') .limit(20) .get(); final ids = { for (final d in names.docs) if (d.data()['fandomId'] case final String id when id.isNotEmpty) id, }; final docs = await Future.wait([ for (final id in ids) db.doc('fandoms/$id').get(), ]); return [ for (final doc in docs) if (doc.data() case final data?) _node(doc.id, data, 0, myFandomId, ranked: false), ]; } PlanetNode _node( String id, Map data, int rank, String myFandomId, { bool ranked = true, }) => PlanetNode( ranked: ranked, id: id, stage: switch (data['stage']) { int n when n >= 0 && n <= 6 => n, _ => null, }, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, planetType: data['planetType'] is String ? data['planetType'] as String : null, name: data.strOr('name', id), rank: rank, fpi: data.intOr('fpiScore'), members: data.intOr('memberCount'), online: data.intOr('onlineCount'), isMine: id == myFandomId, ); @override Future setPrimary(String fandomId) => _backend.call('fnPrimaryFandomSet', {'fandomId': fandomId}); /// ZAP — 행성 성장 기여(B-23). 차감은 `fn-pop-spend` 가 한다. /// /// `count` 를 함께 보내 **원장 한 행에 묶음 횟수를 남긴다**. 연타를 한 호출로 /// 묶으면 `amount` 만으로는 횟수를 복원할 수 없어, MY 의 「Zap N 회」가 행 수로 /// 계산되어 실제보다 작게 나온다. @override Future zap({ required String fandomId, required int count, required String clientSeq, }) async { final snapshot = await _backend.db.doc('config/policy').get(); final zapPop = (snapshot.data() ?? const {}).intOr( 'zapPop', 10, ); final data = await _backend.call('fnPopSpend', { 'popType': 'activity', 'amount': zapPop * count, 'count': count, 'reasonCode': 'ZAP_CONTRIBUTION', 'idempotencyKey': 'zap:$fandomId:$clientSeq', 'fandomId': fandomId, 'refPath': 'fandoms/$fandomId', }); final lots = data['lotRefs']; final contributedPop = lots is List && lots.isNotEmpty && lots.every( (lot) => lot is Map && lot['delta'] is int && (lot['delta'] as int) < 0, ) ? lots.fold(0, (sum, lot) => sum - (lot['delta'] as int)) : null; return ZapResult( contributedPop: contributedPop, txId: data['txId'] as String? ?? '', balanceAfter: (data['balanceAfter'] as num?)?.toInt() ?? -1, ); } GalaxyEvent _event(Map data) => GalaxyEvent( kind: data.strOr('kind') == 'eclipse' ? GalaxyEventKind.eclipse : GalaxyEventKind.itemUsed, message: data.strOr('message'), ); }