/// 갤럭시 맵이 그리는 값의 형태. /// /// 화면과 데이터를 갈라 둔 이유는 홈과 같다 — 5주차 Firestore 연동 때 /// 구현체만 갈아 끼운다. /// /// 근거는 Figma `03_Wireframe` 「갤럭시맵 화면」 명세다. /// *데이터 : 3d 갤럭시 — FPI 상세 지수 변동 데이터 · HUD — Rank, FPI 총점, 접속자수(멤버수)* library; /// 맵 위의 행성 하나. class PlanetNode { const PlanetNode({ required this.id, required this.name, required this.rank, required this.fpi, required this.members, required this.online, this.isMine = false, this.stage, this.colorKey, bool? isCenter, }) : isCenter = isCenter ?? isMine; /// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때 /// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다. final String id; /// 팬덤 서버 Stage 0~6; 미제공이면 null. final int? stage; /// 팬덤 `colorKey`(행성 색 팔레트 `planetColors` 의 키). 미제공이면 null. final String? colorKey; final String name; /// 갤럭시 전체 순위. HUD 의 `Rank`. /// /// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고 /// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다. final int rank; /// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다. final int fpi; final int members; /// 접속자 수. 명세의 「접속자수(맴버수)」. final int online; /// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」. final bool isMine; /// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21). final bool isCenter; } /// 하단 이벤트 띠에 흐르는 한 줄. /// /// 명세 — *이벤트1 아이템 사용(닉네임·아이템명) · 이벤트2 이클립스(행성/성운명)*. /// 디폴트는 숨김이고 이벤트가 나면 우→좌로 1회 흐른다. enum GalaxyEventKind { itemUsed, eclipse } class GalaxyEvent { const GalaxyEvent({required this.kind, required this.message}); final GalaxyEventKind kind; final String message; } class GalaxySnapshot { const GalaxySnapshot({ required this.planets, required this.myPop, this.zapPop = 10, this.event, this.joined = true, this.memberships = const [], }); final List planets; /// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」). /// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다. final List memberships; /// 내 활동 POP 잔액. Zap 이 여기서 차감된다. final int myPop; /// ZAP 1 회 기여 POP. `config/policy.zapPop` — Figma 「Zap 버튼」이 /// 「어드민에서 설정한 기여 POP」이라 못 박은 값이다. final int zapPop; final GalaxyEvent? event; /// 행성·성운에 가입했거나 만든 사용자인가. /// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼). final bool joined; PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull; /// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데 /// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로 /// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다. GalaxySnapshot get rankingView { PlanetNode? top; for (final p in planets) { if (p.stage != 0 && (top == null || p.rank < top.rank)) top = p; } return GalaxySnapshot( planets: [ for (final p in planets) PlanetNode( id: p.id, name: p.name, rank: p.rank, fpi: p.fpi, members: p.members, online: p.online, isMine: p.isMine, stage: p.stage, isCenter: identical(p, top), ), ], myPop: myPop, zapPop: zapPop, event: event, joined: joined, memberships: memberships, ); } } /// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다. class ZapResult { const ZapResult({required this.txId, required this.balanceAfter, this.contributedPop}); final String txId; /// 서버 차감 lot 합계. 누락되면 요청액으로 추정하지 않는다. final int? contributedPop; /// 차감 후 잔액. **-1 은 「서버가 알려 주지 않았다」** 로, 화면은 직전 값을 지킨다. final int balanceAfter; } abstract interface class GalaxyRepository { Future load(); /// ZAP — **행성 성장 기여**다. 이클립스 방어가 아니다. /// /// 별첨 B **B-23** — *ZAP 차감액은 누적 기여 POP 에 실제 값으로 반영되고, 행성 /// 성장용 Growth 진행도에만 가중을 둔다(원장·sFPI 에는 가중 미적용)*. Figma /// 「Zap 버튼」의 *「어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가」* /// 가 이 경로다. /// /// 차감은 `fn-pop-spend` 가 한다 — 화면이 POP 을 직접 깎지 않는다(별첨 1 ②). /// [count] 는 연타로 묶은 횟수이며 **원장 행에 그대로 남는다** — MY 의 POP 내역이 /// 「Zap N 회」를 행 수로 세면 실제보다 작게 나온다. Future zap({ required String fandomId, required int count, required String clientSeq, }); /// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면 /// E-FDM-04 로 거부된다. Future setPrimary(String fandomId); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** class SampleGalaxyRepository implements GalaxyRepository { const SampleGalaxyRepository({this.joined = true}); final bool joined; @override Future setPrimary(String fandomId) async {} /// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다. @override Future zap({ required String fandomId, required int count, required String clientSeq, }) async => const ZapResult(txId: '', balanceAfter: -1); @override Future load() async => GalaxySnapshot( myPop: 12540, joined: joined, event: const GalaxyEvent( kind: GalaxyEventKind.itemUsed, message: '아미보이님이 메가 드랍을 사용했습니다', ), planets: const [ PlanetNode( id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 1204, isMine: true, ), PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 198740, members: 8320, online: 611), PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 176250, members: 7640, online: 540), PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 152080, members: 6910, online: 402), ], memberships: const [ PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 1204, isMine: true), PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), ], ); }