/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태. /// /// **방어는 팬덤 공동 Shake 참여이며 POP 을 소모하지 않는다.** /// 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · /// Firestore 설계서 v1.1 §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 /// 두지 않습니다. 하루 2 회, 세부 수치는 `config` 운영값*. /// /// 별첨 1 ① 시나리오 4 의 「번개 버튼 연타·오토 도네이션」은 별첨 B 가 상위 기준이므로 /// 따르지 않는다(공통 기준 2항). GALAXY 의 ZAP 버튼은 이클립스 방어가 아니라 /// **행성 성장 기여**다(B-23) — [GalaxyRepository] 가 맡는다. /// /// v1.5 §5 확정: 수동 방어는 Shake only · POP 무소모, HOME ZAP은 성장 기여다. /// /// 수치는 **전부 서버 산출값**이다. 화면은 침식률을 계산하지 않는다 — 단말마다 /// 다른 값을 그리면 「방어했는데 침식률이 안 준다」가 된다. library; /// 활성 이클립스 한 건의 현재 상태. enum EclipseResult { success, failure, cancelled } class EclipseState { const EclipseState({ required this.fandomId, required this.eclipseId, required this.fandomName, required this.erosionRate, required this.defense, required this.targetDefense, required this.defenseCount, required this.defended, this.qualifyShakes = 60, this.startAt, this.scheduled = false, this.endsAt, this.result, this.mockNote, }); final String fandomId; final String eclipseId; /// 배너 문구의 「{행성}이 침식되고 있습니다」 자리. final String fandomName; /// 서버가 산출한 침식률 0~100. `fn-eclipse-aggregate` 의 `erosionRate`. final int erosionRate; /// 서버가 60회 이상으로 인정한 고유 참여자 수. 샤드 합으로 즉시 반영한다. final int defense; /// DAU 평균과 목표율로 서버가 확정한 목표 인원. `eclipseRounds.target`(C-26). final int targetDefense; /// 고유 참여자 1명으로 인정하는 개인 유효 Shake 수. 회차 문서의 운영값(C-25 기본 60). final int qualifyShakes; /// 방어에 참여한 횟수 합. final int defenseCount; /// 목표를 채워 방어에 성공했는가. final bool defended; /// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다. final EclipseResult? result; bool get canDefend => !scheduled && canShake(DateTime.now()); /// 흔들기를 받을 때인가 — 발생 시각이 지났고 아직 열려 있다. 서버가 회차를 `active` 로 /// 바꾸기 전(스케줄러 지연 수 초)의 흔들기는 모아 두었다가 [canDefend] 가 되면 보낸다. bool canShake(DateTime now) => result == null && !defended && endsAt != null && hasStarted(now) && !isExpired(now); /// 발생 시각. 사전 알림(`warnAt`)부터 문서가 있으므로 그 전에는 남은 시간을 센다. /// 없으면 이미 시작한 회차로 본다. final DateTime? startAt; /// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다. final bool scheduled; bool hasStarted(DateTime now) => startAt == null || !startAt!.isAfter(now); /// 방어 마감. 없으면 남은 시간을 띄우지 않는다. final DateTime? endsAt; /// 실데이터가 아닌 경우의 사유. 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출). final String? mockNote; bool get isMock => mockNote != null; /// 방어 진행률 0~1. 목표가 0 이면 진행률을 말할 수 없다. double get progress => targetDefense <= 0 ? 0 : (defense / targetDefense).clamp(0.0, 1.0); /// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 D3) — /// 서버가 `ended` 로 닫기 전이라도 화면이 먼저 접는다. bool isExpired(DateTime now) { final at = endsAt; return at != null && !at.isAfter(now); } /// 남은 시간. 마감이 없거나 지났으면 null. Duration? remaining(DateTime now) { final at = endsAt; if (at == null) return null; final left = at.difference(now); return left.isNegative ? null : left; } } /// 방어 한 번의 결과. **서버가 확정한 값만 담는다.** class DefendResult { const DefendResult({ required this.accepted, required this.clamped, required this.myTotal, required this.duplicate, }); /// 이번 참여로 집계에 더해진 방어량. 상한에 걸리면 보낸 값보다 작다. final int accepted; /// 상한 때문에 깎인 양. 화면이 「참여 상한에 닿았습니다」를 띄우는 근거다. final int clamped; /// 이 이벤트에서 내가 지금까지 쌓은 방어량. final int myTotal; /// 재시도로 판정되어 아무것도 더하지 않았는가. final bool duplicate; } /// 라이브 소셜 프루프 한 줄 — `fandoms/{id}/liveEvents` 한 문서. /// /// 별첨 B **「Global Ticker」** 확정에 따라 이 컬렉션은 이클립스 전용이 아니다 — /// *Eclipse·Artist Hot Time·Hero Gift·Campaign·공지를 하나의 Global Ticker 로 통합하고 /// 우선순위·클릭이동처를 운영설정으로 관리*. 그래서 [kind] 로 갈라 두었다. class LiveEvent { const LiveEvent({ required this.id, required this.nickname, required this.at, this.kind = LiveEventKind.eclipseDefense, this.defense = 0, this.priority = 50, this.linkTo = '', }); /// 문서 ID. 같은 줄을 두 번 흘리지 않도록 위젯이 키로 쓴다. final String id; final String nickname; /// 이클립스 방어 참여량. 다른 종류에서는 0 이다. final int defense; final DateTime at; final LiveEventKind kind; /// 낮을수록 먼저 뜬다. 종류별 값은 운영설정에서 온다. final int priority; /// 눌렀을 때 갈 곳. 운영설정 항목이라 화면이 만들지 않는다. final String linkTo; } /// 티커 이벤트 종류. 서버의 `kind` 문자열과 1:1 이다. enum LiveEventKind { eclipseDefense('eclipse_defense'), artistHotTime('artist_hot_time'), heroGift('hero_gift'), campaign('campaign'), notice('notice'); const LiveEventKind(this.wire); /// Firestore 에 적힌 값. 이 문자열이 서버와의 계약면이다. final String wire; /// 모르는 값은 공지로 읽는다 — 새 종류가 늘어도 티커가 멈추지 않는다. static LiveEventKind of(String value) => values.where((k) => k.wire == value).firstOrNull ?? LiveEventKind.notice; } /// 이클립스가 화면에 닿는 유일한 경로. /// /// 읽기는 Firestore 구독, **쓰기(방어)는 Cloud Functions** 다 — 별첨 1 ② /// 「클라이언트 단독 지급 0건」. 방어는 POP 을 쓰지 않지만 집계를 움직이므로 /// 클라이언트가 직접 쓰지 못하게 같은 경계를 지킨다. abstract interface class EclipseRepository { /// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다. /// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다. /// includeScheduled는 사전 알림(`warnAt`)부터의 `scheduled` 회차도 받는다 — HOME 이클립스 /// 이벤트 버튼(`516:88125` 「발생 N분 전 노출」)과 이클립스 상세가 쓴다. Stream watch({ String? fandomId, bool includeResults = false, bool includeScheduled = false, }); /// 최근 티커 이벤트. 최신순. Stream> liveEvents({String? fandomId, int limit = 20}); /// 공동 Shake 참여. [shakes] 는 단말이 감지한 흔들기 수이며 /// **서버가 상한과 속도로 다시 판정한다**(별첨 1 ② 서버검증 원칙). Future defend({ required String fandomId, required String eclipseId, required String clientSeq, required int shakes, }); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** /// /// 활성 이클립스를 하나 띄워 두는 이유는 별첨 2 의 확인 항목 때문이다. 목 모드에서 /// 배너가 아예 뜨지 않으면 「이클립스 화면이 없다」로 읽힌다. 대신 `mockNote` 를 /// 실어 화면이 표본임을 배지로 밝힌다. class SampleEclipseRepository implements EclipseRepository { SampleEclipseRepository(); static const _note = '표본 이클립스 — 서버 집계 미연동'; /// 표본에서도 참여가 쌓이는 것이 보여야 한다. 방어 호출이 이 값을 올린다. int _defense = 5_800; EclipseState _snapshot({String? fandomId}) => EclipseState( fandomId: fandomId ?? 'bts', eclipseId: 'sample-eclipse', fandomName: 'BTS', erosionRate: 42, defense: _defense, targetDefense: 10_000, defenseCount: 1_340, defended: false, endsAt: DateTime.now().add(const Duration(minutes: 18)), mockNote: _note, ); @override Stream watch({ String? fandomId, bool includeResults = false, bool includeScheduled = false, }) => Stream.value(_snapshot(fandomId: fandomId)); @override Stream> liveEvents({String? fandomId, int limit = 20}) { final now = DateTime.now(); return Stream.value([ LiveEvent(id: 'e1', nickname: '아미보이', defense: 12, at: now), LiveEvent( id: 'e2', nickname: '버니즈', defense: 8, at: now.subtract(const Duration(seconds: 12)), ), ].take(limit).toList(growable: false)); } @override Future defend({ required String fandomId, required String eclipseId, required String clientSeq, required int shakes, }) async { _defense += shakes; return DefendResult( accepted: shakes, clamped: 0, myTotal: shakes, duplicate: false, ); } }