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

279 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태.
///
/// **방어는 팬덤 공동 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);
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 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<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,
);
}
}