HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태.
|
||||
///
|
||||
/// **방어는 팬덤 공동 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.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 => result == null && !defended && endsAt != null && !isExpired(DateTime.now());
|
||||
|
||||
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
||||
final DateTime? endsAt;
|
||||
|
||||
/// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5).
|
||||
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는 방어 화면에서 마감 후 판정까지 받는다.
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = 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}) =>
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user