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
@@ -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}')