279 lines
10 KiB
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);
|
||
|
||
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 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,
|
||
);
|
||
}
|
||
}
|