232 lines
9.0 KiB
Dart
232 lines
9.0 KiB
Dart
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<GalaxySnapshot> load() async {
|
|
final uid = _backend.requireUid;
|
|
final db = _backend.db;
|
|
|
|
final userSnap = await db.doc('users/$uid').get();
|
|
final user = userSnap.data() ?? const <String, dynamic>{};
|
|
// 대표 팬덤(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 <String, dynamic>{};
|
|
final events = (await eventF).docs;
|
|
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
|
|
|
// 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는
|
|
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
|
|
// 랭킹 모집단 밖(B-08 · B-17)은 순위를 매기기 **전에** 뺀다 — 끼면 정식 행성의 순위
|
|
// 번호가 밀린다. Stage0 성운과 서버가 `rankingEligible: false`(최근 7 일 유효 활동 없음)로
|
|
// 내린 행성이다. 이들은 맨 바깥 궤도 자리(모집단 수 + 1)에 두고 순위는 「-」로 그린다.
|
|
// 필드가 아직 없는 문서(집계 배포 전)는 Stage 만으로 판정한다.
|
|
bool unranked(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
|
|
d.data()['stage'] == 0 || d.data()['rankingEligible'] == false;
|
|
final formal = fandoms.where((d) => !unranked(d)).toList();
|
|
final planets = <PlanetNode>[
|
|
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<String>(),
|
|
if (myFandomId.isNotEmpty) myFandomId,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 전체 행성 이름 접두 검색 — 이름 색인 `fandomNames/{소문자 이름}`(서버가 성운 생성 때 쓴다).
|
|
/// 맵 상위 [visibleCount] 개 안에서만 거르던 것을 고쳤다(Figma `353:217832` 「행성 검색으로 대상 행성 탐색」).
|
|
/// ponytail: 접두 일치만 — 중간 글자 일치가 필요하면 검색 색인(n-gram)을 서버에 둔다.
|
|
@override
|
|
Future<List<PlanetNode>> 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 <String, dynamic>{};
|
|
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<String, dynamic> 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<void> setPrimary(String fandomId) =>
|
|
_backend.call('fnPrimaryFandomSet', {'fandomId': fandomId});
|
|
|
|
/// ZAP — 행성 성장 기여(B-23). 차감은 `fn-pop-spend` 가 한다.
|
|
///
|
|
/// `count` 를 함께 보내 **원장 한 행에 묶음 횟수를 남긴다**. 연타를 한 호출로
|
|
/// 묶으면 `amount` 만으로는 횟수를 복원할 수 없어, MY 의 「Zap N 회」가 행 수로
|
|
/// 계산되어 실제보다 작게 나온다.
|
|
@override
|
|
Future<ZapResult> 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 <String, dynamic>{}).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<int>(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<String, dynamic> data) => GalaxyEvent(
|
|
kind: data.strOr('kind') == 'eclipse'
|
|
? GalaxyEventKind.eclipse
|
|
: GalaxyEventKind.itemUsed,
|
|
message: data.strOr('message'),
|
|
);
|
|
}
|