FPI 사용자·국가 랭킹 서버 집계 · YouTube 스밍 · Music Shake 참여

This commit is contained in:
Sn-Kinos
2026-10-05 17:49:47 +09:00
parent 5238fc4ef3
commit 96a95a12d1
31 changed files with 738 additions and 116 deletions
+14 -2
View File
@@ -20,7 +20,9 @@ class PlanetNode {
this.stage,
this.colorKey,
bool? isCenter,
}) : isCenter = isCenter ?? isMine;
bool? ranked,
}) : isCenter = isCenter ?? isMine,
ranked = ranked ?? stage != 0;
/// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때
/// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다.
@@ -38,8 +40,16 @@ class PlanetNode {
///
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
/// 최근 7 일 유효 활동이 없는 정식 행성도 같다([ranked]).
final int rank;
/// FPI 랭킹 모집단인가(B-17 — 정식 행성 중 최근 7 일 유효 활동 존재). false 면 [rank] 는
/// 궤도 자리일 뿐이라 화면은 순위 대신 「-」를 쓴다.
final bool ranked;
/// 화면에 쓰는 순위 문구 — 모집단 밖이면 「-」.
String get rankLabel => ranked ? '$rank' : '-';
/// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다.
final int fpi;
@@ -104,7 +114,7 @@ class GalaxySnapshot {
GalaxySnapshot get rankingView {
PlanetNode? top;
for (final p in planets) {
if (p.stage != 0 && (top == null || p.rank < top.rank)) top = p;
if (p.ranked && (top == null || p.rank < top.rank)) top = p;
}
return GalaxySnapshot(
planets: [
@@ -118,6 +128,8 @@ class GalaxySnapshot {
online: p.online,
isMine: p.isMine,
stage: p.stage,
colorKey: p.colorKey,
ranked: p.ranked,
isCenter: identical(p, top),
),
],
@@ -57,11 +57,13 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
// 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
d.data()['stage'] == 0;
final formal = fandoms.where((d) => !isNebula(d)).toList();
// 랭킹 모집단 밖(B-08 · B-17)은 순위를 매기기 **전에** 뺀다 — 끼면 정식 행성의 순위
// 번호가 밀린다. Stage0 성운과 서버가 `rankingEligible: false`(최근 7 일 유효 활동 없음)로
// 내린 행성이다. 이들은 맨 바깥 궤도 자리(모집단 수 + 1)에 두고 순위는 「-」로 그린다.
// 필드가 아직 없는 문서(집계 배포 전)는 Stage 만으로 판정한다.
bool unranked(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
d.data()['stage'] == 0 || d.data()['rankingEligible'] == false;
final formal = fandoms.where((d) => !unranked(d)).toList();
final planets = <PlanetNode>[
for (final (doc, rank) in rankWithTies(
formal,
@@ -69,8 +71,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
(d) => d.id,
))
_node(doc.id, doc.data(), rank, myFandomId),
for (final doc in fandoms.where(isNebula))
_node(doc.id, doc.data(), formal.length + 1, myFandomId),
for (final doc in fandoms.where(unranked))
_node(doc.id, doc.data(), formal.length + 1, myFandomId, ranked: false),
];
final byId = {for (final p in planets) p.id: p};
final membershipIds = [for (final d in (await membershipsF).docs) d.id];
@@ -81,8 +83,15 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
]);
for (final doc in missing) {
final data = doc.data();
if (data != null)
byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId);
if (data != null) {
byId[doc.id] = _node(
doc.id,
data,
formal.length + 1,
myFandomId,
ranked: false,
);
}
}
return GalaxySnapshot(
@@ -100,8 +109,10 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
String id,
Map<String, dynamic> data,
int rank,
String myFandomId,
) => PlanetNode(
String myFandomId, {
bool ranked = true,
}) => PlanetNode(
ranked: ranked,
id: id,
stage: switch (data['stage']) {
int n when n >= 0 && n <= 6 => n,
+2 -5
View File
@@ -909,11 +909,8 @@ class _HudCard extends StatelessWidget {
style: t.titleSmall,
),
),
// Stage0 성운은 랭킹 모집단이 아니다(B-17).
_Metric(
label: 'RANK',
value: planet.stage == 0 ? '-' : '${planet.rank}',
),
// Stage0 성운 · 최근 7 일 활동 없는 행성은 랭킹 모집단이 아니다(B-17).
_Metric(label: 'RANK', value: planet.rankLabel),
],
),
const SizedBox(height: AppSpace.s1),
@@ -286,7 +286,7 @@ class _Hit extends StatelessWidget {
child: Row(
children: [
Text(
'${planet.rank}',
planet.rankLabel,
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
),
const SizedBox(width: AppSpace.s4),
@@ -234,7 +234,7 @@ class _PlanetHit extends StatelessWidget {
height: side,
child: Semantics(
button: true,
label: context.l10n.galaxyPlanetRank(planet.name, '${planet.rank}'),
label: context.l10n.galaxyPlanetRank(planet.name, planet.rankLabel),
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -446,7 +446,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
static List<String> hudLines(PlanetNode p) => [
p.name.toUpperCase(),
'RANK #${p.rank}',
'RANK #${p.rankLabel}',
'FPI: ${p.fpi}',
'LIVE: ${formatPop(p.online)}',
];
@@ -515,7 +515,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
/// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
static List<String> compactLines(PlanetNode p) => [
'#${p.rank} · FPI ${formatPop(p.fpi)}',
'#${p.rankLabel} · FPI ${formatPop(p.fpi)}',
'LIVE ${formatPop(p.online)}',
];
@@ -134,9 +134,9 @@ class FirestorePlanetRepository implements PlanetRepository {
final axes = _map(fandom, 'fpiAxes');
final ranks = _map(fandom, 'fpiRanks');
final myTop = _map(member, 'topPercentByAxis');
// MOCK: `topPercentByAxis` 는 서버 산출값인데 이를 쓰는 집계 함수가 아직
// 없습니다. 필드는 실연동으로 읽고, **값이 없으면 null** 로 둡니다 —
// 100%(최하위)로 채우면 없는 값이 사실처럼 보입니다. 대조표 3-5 참조.
// `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다.
// **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로
// 채우면 없는 값이 사실처럼 보인다.
int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num
? myTop.intOr(_axisKey(metric))
: null;
@@ -189,9 +189,7 @@ class FirestorePlanetRepository implements PlanetRepository {
posts: posts,
// 한 축이라도 서버 값이 없으면 배지로 알린다. 화면이 이 문구를 그대로
// 띄운다 — 「집계 준비 중」 카드만으로는 이유가 보이지 않는다(대조표 3-5).
mockNote: FpiMetric.values.any((m) => myTopOf(m) == null)
? '「내 기여 상위 %」 집계 함수가 아직 없습니다'
: null,
mockNote: null,
);
}
@@ -288,18 +286,7 @@ class FirestorePlanetRepository implements PlanetRepository {
(d) => _raw(d.data(), axis),
(d) => d.id,
))
FpiRankRow(
id: doc.id,
rank: rank,
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
name: publicName(doc.data()['nickname']),
value: _map(doc.data(), 'fpiAxes').intOr(axis),
// MOCK: 멤버의 직전 축별 순위(`rankPrevByAxis`)를 쓰는 서버 집계가
// 아직 없습니다. 필드가 없으면 현재 순위를 그대로 넣어 동향이 0 이 되고,
// 화면은 ▲▼ 를 그리지 않습니다. 대조표 3-5 참조.
change: _map(doc.data(), 'rankPrevByAxis').intOr(axis, rank) - rank,
isMine: doc.id == uid,
),
_userRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid),
];
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
@@ -317,22 +304,45 @@ class FirestorePlanetRepository implements PlanetRepository {
// Figma `546:14668` 「나의 랭킹 영역」은 내가 몇 등이든 떠야 한다.
final me = (await members.doc(uid).get()).data();
if (me == null) return FpiRanking(rows: rows, mockNote: note);
final myValue = _map(me, 'fpiAxes').intOr(axis);
// 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19). 반올림한 값으로 세면 동률이 생긴다.
final above = await members
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
.count()
.get();
// 서버 순위가 없을 때만 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19)로 센다.
final serverRank = _map(me, 'rankByAxis')[axis];
final fallback = serverRank is num
? 0
: ((await members
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
.count()
.get())
.count ??
rows.length) +
1;
return FpiRanking(
rows: rows,
mockNote: note,
mine: FpiRankRow(
id: uid,
rank: (above.count ?? rows.length) + 1,
name: publicName(me['nickname'], appL10n.commonMe),
value: myValue,
isMine: true,
),
mine: _userRow(me, id: uid, axis: axis, fallbackRank: fallback, uid: uid,
name: appL10n.commonMe),
);
}
/// 사용자 랭킹 한 줄. 순위 · 직전 순위는 서버 5 분 집계(`rankByAxis` · `rankPrevByAxis`)를
/// 읽는다 — 상위 % 와 같은 원천이라 두 값이 갈리지 않는다. 서버 값이 없을 때만 [fallbackRank].
static FpiRankRow _userRow(
Map<String, dynamic> data, {
required String id,
required String axis,
required int fallbackRank,
required String uid,
String? name,
}) {
final rank = _map(data, 'rankByAxis').intOr(axis, fallbackRank);
return FpiRankRow(
id: id,
rank: rank,
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
name: publicName(data['nickname'], name),
value: _map(data, 'fpiAxes').intOr(axis),
// 순위 동향 = 직전 순위 − 현재 순위. 직전 값이 없으면 0(▲▼ 없음).
change: _map(data, 'rankPrevByAxis').intOr(axis, rank) - rank,
isMine: id == uid,
);
}
+13 -15
View File
@@ -60,7 +60,8 @@ enum QuestType {
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
timeAttack,
/// 스밍·YouTube. 별첨 B 「1차 별도 완청 보상형 Quest 없음」 + Q-QC-04 회신 대기.
/// 스밍·YouTube — 무보상 기본 참여(원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과
/// FPI 원천만 남는다(`fn-quest-stream-participate`).
streaming,
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
@@ -109,6 +110,8 @@ enum QuestType {
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
if (this == QuestType.streaming) return 0;
final raw = this == QuestType.attend
? policy['attendPop']
: quest['rewardPop'];
@@ -117,19 +120,13 @@ enum QuestType {
/// 앱이 서버 검증까지 태울 수 있는 유형인가.
///
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
/// [streaming] 은 무보상 참여(C-04), [musicShake] 는 BPM 구간 판정(B-24 · `fn-quest-verify-music-shake`)으로
/// 연다(Q-QC-04 9/16 회신). [unknown] 은 판정 함수를 모른다.
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
bool get verifiable => switch (this) {
QuestType.attend ||
QuestType.steps ||
QuestType.place ||
QuestType.timeAttack ||
QuestType.vote ||
QuestType.sns ||
QuestType.community => true,
QuestType.musicShake || QuestType.streaming || QuestType.unknown => false,
};
bool get verifiable => this != QuestType.unknown;
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason =>
@@ -645,14 +642,15 @@ class SampleQuestRepository implements QuestRepository {
// 잠근 상태를 표본에서도 보여 준다.
const QuestCard(
id: 'q6',
title: '신곡 3분 완청',
title: '신곡 스트리밍 참여',
categoryName: '스트리밍',
type: QuestType.streaming,
author: QuestAuthor.admin,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
rewardPop: 10,
// 무보상 기본 참여(C-04).
rewardPop: 0,
participants: 3420,
),
];
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/ui/app_dialog.dart';
@@ -86,8 +88,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
});
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
if (_stage == QuestStage.inProgress &&
detail.card.type == QuestType.steps) {
if (_stage == QuestStage.inProgress && detail.card.type.gathers) {
_watch(detail.card);
}
}
@@ -593,7 +594,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
case QuestStage.notStarted:
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
if (detail.card.goal > 1 && detail.card.type == QuestType.steps) {
if (detail.card.goal > 1 && detail.card.type.gathers) {
await _start(detail);
} else {
await _claim(detail);
@@ -634,6 +635,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
.listen(
(value) {
if (!mounted) return;
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
setState(() {
_live = value;
if (card.goal > 0 && value >= card.goal) {
@@ -712,6 +715,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_busy = false;
_stage = QuestStage.rewarded;
});
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
if (detail.card.type == QuestType.streaming) {
_toast(context.l10n.questStreamJoined);
final url = Uri.tryParse(detail.link?.url ?? '');
if (url != null && url.hasScheme) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
await _load();
return;
}
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
if (reward.duplicate) {
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
@@ -8,8 +8,11 @@
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
library;
import 'dart:math' as math;
import 'package:geolocator/geolocator.dart';
import 'package:pedometer/pedometer.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -42,6 +45,72 @@ typedef StepCountProbe = Stream<int> Function();
/// 현재 좌표 한 점을 내는 함수.
typedef PlaceProbe = Future<PlaceFix> Function();
/// 흔들기 한 번마다 그 시각을 내는 스트림.
typedef ShakeProbe = Stream<DateTime> Function();
/// 흔들기 감지 — 가속도 크기가 중력(9.8)을 크게 넘는 순간. 이클립스 방어 화면과 같은 문턱 ·
/// 간격이다(같은 동작을 같은 기준으로 센다).
Stream<DateTime> deviceShakes() async* {
var last = DateTime.fromMillisecondsSinceEpoch(0);
await for (final e in accelerometerEventStream()) {
final magnitude = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
final now = DateTime.now();
if (!magnitude.isFinite || magnitude < 18 ||
now.difference(last) < const Duration(milliseconds: 220)) {
continue;
}
last = now;
yield now;
}
}
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
///
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
class ShakeJudge {
ShakeJudge({required this.bpm, required this.segments, required this.start})
: assert(bpm > 0 && segments > 0);
final double bpm;
final int segments;
final DateTime start;
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
static const perfectRatio = 0.2;
final _shakes = <DateTime>[];
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
DateTime get end => start.add(beat * segments - beat ~/ 2);
void add(DateTime at) => _shakes.add(at);
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
Map<String, int> judgement() {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
final beatUs = beat.inMicroseconds;
for (var i = 0; i < segments; i++) {
final at = start.microsecondsSinceEpoch + beatUs * i;
final best = _shakes
.map((s) => (s.microsecondsSinceEpoch - at).abs())
.where((d) => d < beatUs / 2)
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
if (best == null) {
miss++;
combo = 0;
continue;
}
best <= beatUs * perfectRatio ? perfect++ : good++;
maxCombo = math.max(maxCombo, ++combo);
}
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
}
}
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
///
/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로
@@ -29,14 +29,23 @@ class FirestoreQuestRepository implements QuestRepository {
this._backend, {
StepCountProbe stepCount = deviceStepCount,
PlaceProbe placeFix = devicePlaceFix,
ShakeProbe shakes = deviceShakes,
WalkBaseline? baseline,
}) : _stepCount = stepCount,
_placeFix = placeFix,
_shakes = shakes,
_baseline = baseline ?? WalkBaseline();
final Backend _backend;
final StepCountProbe _stepCount;
final PlaceProbe _placeFix;
final ShakeProbe _shakes;
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
ShakeJudge? _shakeRun;
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
static const defaultBpm = 100.0;
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
final WalkBaseline _baseline;
@@ -157,6 +166,9 @@ class FirestoreQuestRepository implements QuestRepository {
final steps = type == QuestType.steps;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
: type == QuestType.musicShake
? quest.intOr('totalSegments', dailyLimit)
: dailyLimit;
return QuestCard(
@@ -175,7 +187,7 @@ class FirestoreQuestRepository implements QuestRepository {
: QuestStage.notStarted,
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
progress: steps ? (done ? goal : 0) : count,
progress: type.gathers ? (done ? goal : 0) : count,
rewardPop: type.rewardFrom(quest, policy),
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
participants: quest.intOr('participantCount'),
@@ -435,6 +447,7 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) {
if (card.type == QuestType.musicShake) return _shakeProgress(card);
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
@@ -460,6 +473,10 @@ class FirestoreQuestRepository implements QuestRepository {
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
QuestType.timeAttack =>
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
QuestType.streaming =>
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
QuestType.musicShake => await _verifyShake(card),
_ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''),
};
@@ -502,6 +519,53 @@ class FirestoreQuestRepository implements QuestRepository {
});
}
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
/// 흔들기는 그동안만 모은다.
Stream<int> _shakeProgress(QuestCard card) async* {
_shakeRun = null;
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{};
final segments = quest.intOr('totalSegments');
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
? (quest['bpm'] as num).toDouble()
: defaultBpm;
if (segments <= 0) {
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
}
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
final sub = _shakes().listen(run.add, onError: (_) {});
try {
for (var i = 1; i < segments; i++) {
await Future<void>.delayed(run.beat);
yield i;
}
await Future<void>.delayed(run.end.difference(DateTime.now()));
_shakeRun = run;
yield segments;
} finally {
await sub.cancel();
}
}
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun;
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{};
final ids = quest['trackIds'];
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
_shakeRun = null;
return _backend.call('fnQuestVerifyMusicShake', {
'questId': card.id,
'trackId': trackId,
'totalSegments': run.segments,
'judgement': run.judgement(),
'occurredAt': run.end.toUtc().toIso8601String(),
...await _fandomField(),
});
}
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
final quest = await _backend.db
.doc('quests/${card.id}')
+2
View File
@@ -758,6 +758,8 @@
"questPoolHelper": "Deposit {pool} POP · Fee {fee} POP",
"questCreateCost": "{title}\nReward {reward} POP × {count} = {pool} POP\nFee {fee} POP (burned on publishing)\n{total} POP in total will be deducted.",
"questNoReward": "This quest has no reward.",
"questStreamJoined": "Your participation is recorded. This quest has no POP reward.",
"questNoShake": "No shakes recorded. Tap Start and shake to the beat.",
"questDeadline": "Deadline",
"questRemainingHours": "{hours}h left",
"questEnded": "Ended",
+2
View File
@@ -758,6 +758,8 @@
"questPoolHelper": "Depósito {pool} POP · Comisión {fee} POP",
"questCreateCost": "{title}\nRecompensa {reward} POP × {count} = {pool} POP\nComisión {fee} POP (se quema al publicar)\nSe descontarán {total} POP en total.",
"questNoReward": "Esta misión no tiene recompensa.",
"questStreamJoined": "Tu participación quedó registrada. Esta misión no otorga POP.",
"questNoShake": "No se registraron sacudidas. Pulsa Empezar y agita al ritmo.",
"questDeadline": "Fecha límite",
"questRemainingHours": "Quedan {hours} h",
"questEnded": "Finalizada",
+2
View File
@@ -758,6 +758,8 @@
"questPoolHelper": "合計 {pool} POP 預け入れ・手数料 {fee} POP",
"questCreateCost": "{title}\n報酬 {reward} POP × {count}人 = {pool} POP\n手数料 {fee} POP(公開時に消滅)\n合計 {total} POP が差し引かれます。",
"questNoReward": "報酬のない参加クエストです。",
"questStreamJoined": "参加を記録しました。このクエストにPOP報酬はありません。",
"questNoShake": "シェイクの記録がありません。スタートを押してビートに合わせて振ってください。",
"questDeadline": "締切",
"questRemainingHours": "残り{hours}時間",
"questEnded": "終了",
+2
View File
@@ -1560,6 +1560,8 @@
}
},
"questNoReward": "무보상 참여 퀘스트예요.",
"questStreamJoined": "참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.",
"questNoShake": "흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.",
"questDeadline": "마감",
"questRemainingHours": "{hours}시간 남음",
"@questRemainingHours": {
+12
View File
@@ -4664,6 +4664,18 @@ abstract class AppLocalizations {
/// **'무보상 참여 퀘스트예요.'**
String get questNoReward;
/// No description provided for @questStreamJoined.
///
/// In ko, this message translates to:
/// **'참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.'**
String get questStreamJoined;
/// No description provided for @questNoShake.
///
/// In ko, this message translates to:
/// **'흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.'**
String get questNoShake;
/// No description provided for @questDeadline.
///
/// In ko, this message translates to:
+8
View File
@@ -2644,6 +2644,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get questNoReward => 'This quest has no reward.';
@override
String get questStreamJoined =>
'Your participation is recorded. This quest has no POP reward.';
@override
String get questNoShake =>
'No shakes recorded. Tap Start and shake to the beat.';
@override
String get questDeadline => 'Deadline';
+8
View File
@@ -2655,6 +2655,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get questNoReward => 'Esta misión no tiene recompensa.';
@override
String get questStreamJoined =>
'Tu participación quedó registrada. Esta misión no otorga POP.';
@override
String get questNoShake =>
'No se registraron sacudidas. Pulsa Empezar y agita al ritmo.';
@override
String get questDeadline => 'Fecha límite';
+6
View File
@@ -2556,6 +2556,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get questNoReward => '報酬のない参加クエストです。';
@override
String get questStreamJoined => '参加を記録しました。このクエストにPOP報酬はありません。';
@override
String get questNoShake => 'シェイクの記録がありません。スタートを押してビートに合わせて振ってください。';
@override
String get questDeadline => '締切';
+6
View File
@@ -2558,6 +2558,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get questNoReward => '무보상 참여 퀘스트예요.';
@override
String get questStreamJoined => '참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.';
@override
String get questNoShake => '흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.';
@override
String get questDeadline => '마감';
+6
View File
@@ -2553,6 +2553,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get questNoReward => '这是无奖励的参与任务。';
@override
String get questStreamJoined => '已记录参与。此任务没有 POP 奖励。';
@override
String get questNoShake => '没有摇动记录。请点击开始并跟着节拍摇动。';
@override
String get questDeadline => '截止';
+2
View File
@@ -758,6 +758,8 @@
"questPoolHelper": "共存入 {pool} POP · 手续费 {fee} POP",
"questCreateCost": "{title}\n奖励 {reward} POP × {count}人 = {pool} POP\n手续费 {fee} POP(发布时销毁)\n共扣除 {total} POP。",
"questNoReward": "这是无奖励的参与任务。",
"questStreamJoined": "已记录参与。此任务没有 POP 奖励。",
"questNoShake": "没有摇动记录。请点击开始并跟着节拍摇动。",
"questDeadline": "截止",
"questRemainingHours": "剩余{hours}小时",
"questEnded": "已截止",
@@ -1834,6 +1834,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questNoReward => _remote['questNoReward'] ?? _base.questNoReward;
@override
String get questNoShake => _remote['questNoShake'] ?? _base.questNoShake;
@override
String get questNoSteps => _remote['questNoSteps'] ?? _base.questNoSteps;
@@ -1927,6 +1930,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questStopConfirm => _remote['questStopConfirm'] ?? _base.questStopConfirm;
@override
String get questStreamJoined => _remote['questStreamJoined'] ?? _base.questStreamJoined;
@override
String get questSubmit => _remote['questSubmit'] ?? _base.questSubmit;