Files
fanuzu/app/lib/features/eclipse/eclipse_data.dart

279 lines
10 KiB
Dart

/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태.
///
/// **방어는 팬덤 공동 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.warnAt,
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;
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 정책 원장 C-22 3분). 행성 그림자 접근의 출발점.
final DateTime? warnAt;
/// 서버 회차가 아직 `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);
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다** —
/// 서버가 `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<EclipseState?> watch({
String? fandomId,
bool includeResults = false,
bool includeScheduled = false,
});
/// 최근 티커 이벤트. 최신순.
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
/// 공동 Shake 참여. [shakes] 는 단말이 감지한 흔들기 수이며
/// **서버가 상한과 속도로 다시 판정한다**(별첨 1 ② 서버검증 원칙).
Future<DefendResult> 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<EclipseState?> watch({
String? fandomId,
bool includeResults = false,
bool includeScheduled = false,
}) => Stream.value(_snapshot(fandomId: fandomId));
@override
Stream<List<LiveEvent>> 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<DefendResult> 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,
);
}
}