퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기
This commit is contained in:
@@ -86,12 +86,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
|
||||
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
|
||||
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
|
||||
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
|
||||
// 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만
|
||||
// 테스트용 fake 는 적은 순서대로 적용한다.
|
||||
Query<Map<String, dynamic>> query = db.collection('quests');
|
||||
// 명세 `518:88395` · `518:88405` *퀘스트 생성 최신순 소팅* — 서버 정렬 + 커서 페이지.
|
||||
// `createdAt` 이 없는 문서는 이 쿼리에서 빠진다. 새 문서는 `fn-admin-write` · `fn-quest-create` 가
|
||||
// 남기고, 예전 문서는 `tools/demo/migrate_v040.mjs quest-created-at` 로 채운다(배포 전 필수).
|
||||
// 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 테스트용 fake 는 적은 순서대로 적용한다.
|
||||
Query<Map<String, dynamic>> query = db
|
||||
.collection('quests')
|
||||
.orderBy('createdAt', descending: true);
|
||||
final cursor = _cursor;
|
||||
if (cursor != null) query = query.startAfterDocument(cursor);
|
||||
query = query.limit(limit);
|
||||
@@ -106,20 +107,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
}
|
||||
final now = ServerClock.now();
|
||||
final policy = await _policy();
|
||||
// 명세 `518:88395` 의 *퀘스트 생성 최신순* 은 페이지 안에서만 맞춘다. 서버 정렬은
|
||||
// 걸지 않았다 — `quests` 에 정렬 키가 정해져 있지 않고, `orderBy` 를 걸면 그 필드가
|
||||
// 없는 문서가 목록에서 통째로 빠진다.
|
||||
final docs = [...snap.docs]..sort((a, b) {
|
||||
final left = _writtenAt(a.data());
|
||||
final right = _writtenAt(b.data());
|
||||
if (left == null && right == null) return 0;
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
});
|
||||
|
||||
final cards = <QuestCard>[];
|
||||
for (final doc in docs) {
|
||||
for (final doc in snap.docs) {
|
||||
final quest = doc.data();
|
||||
if (!_open(quest, now, uid)) continue;
|
||||
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
|
||||
@@ -133,7 +123,15 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
} on Exception {
|
||||
progress = const {};
|
||||
}
|
||||
final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest));
|
||||
final card = _card(
|
||||
doc.id,
|
||||
quest,
|
||||
progress,
|
||||
uid,
|
||||
policy,
|
||||
await _submission(doc.id, quest),
|
||||
await _walkFor(quest, progress),
|
||||
);
|
||||
// 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
|
||||
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
|
||||
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
|
||||
@@ -143,14 +141,45 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
return cards;
|
||||
}
|
||||
|
||||
/// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다
|
||||
/// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다.
|
||||
DateTime? _writtenAt(Map<String, dynamic> quest) {
|
||||
for (final key in ['createdAt', 'updatedAt']) {
|
||||
final value = quest[key];
|
||||
if (value is Timestamp) return value.toDate();
|
||||
/// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(원장 C-62 · C-83).
|
||||
/// Health 를 한 번 읽고, 못 쓰면 이번 실행에서 센서로 센 값, 그것도 없으면 null(세션의 마지막 값).
|
||||
Future<int?> _walkFor(Map<String, dynamic> quest, Map<String, dynamic> progress) async {
|
||||
if (QuestType.parse(quest.strOr('type')) != QuestType.steps || progress['session'] is! Map) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
try {
|
||||
final health = await _todaySteps();
|
||||
if (health != null) return health;
|
||||
} on Exception {
|
||||
// 권한 없음 — 아래 값으로.
|
||||
}
|
||||
return _walkSteps > 0 ? _walkSteps : null;
|
||||
}
|
||||
|
||||
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
|
||||
static QuestSession? _sessionOf(Map<String, dynamic> progress) {
|
||||
final raw = progress['session'];
|
||||
if (raw is! Map) return null;
|
||||
final m = Map<String, dynamic>.from(raw);
|
||||
final state = switch (m.strOr('state')) {
|
||||
'inProgress' => QuestStage.inProgress,
|
||||
'paused' => QuestStage.paused,
|
||||
'completed' => QuestStage.completed,
|
||||
_ => null,
|
||||
};
|
||||
if (state == null) return null;
|
||||
final hits = m['hits'];
|
||||
final judgement = m['judgement'];
|
||||
return QuestSession(
|
||||
state: state,
|
||||
progress: m.intOr('progress'),
|
||||
total: m.intOr('total'),
|
||||
position: Duration(milliseconds: m.intOr('positionMs')),
|
||||
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
|
||||
judgement: judgement is Map
|
||||
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`.
|
||||
@@ -185,6 +214,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
String uid,
|
||||
Map<String, dynamic> policy, [
|
||||
QuestSubmission? submission,
|
||||
int? walk,
|
||||
]) {
|
||||
final type = QuestType.parse(quest.strOr('type'));
|
||||
final authorUid = quest.strOr('authorUid');
|
||||
@@ -199,8 +229,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
: type == QuestType.musicShake
|
||||
? quest.intOr('bpm')
|
||||
: dailyLimit;
|
||||
final session = _sessionOf(progress);
|
||||
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
|
||||
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
|
||||
final walked = steps && session != null ? walk ?? session.progress : null;
|
||||
|
||||
return QuestCard(
|
||||
session: session,
|
||||
id: id,
|
||||
title: questTitle(quest),
|
||||
categoryName: type.category,
|
||||
@@ -213,10 +248,20 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
// 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03).
|
||||
: submission?.pending == true
|
||||
? QuestStage.inProgress
|
||||
: QuestStage.notStarted,
|
||||
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
|
||||
: session == null
|
||||
? QuestStage.notStarted
|
||||
: walked != null
|
||||
// Walk 는 일시 정지가 없다 — 목표에 닿았으면 진행완료, 아니면 진행중(C-83).
|
||||
? (cardGoal > 0 && walked >= cardGoal
|
||||
? QuestStage.completed
|
||||
: QuestStage.inProgress)
|
||||
// 화면 안에서 진행하는 Quest 가 진행 화면 밖에 있으면 일시 정지다(앱이 꺼진 경우 포함).
|
||||
: session.state == QuestStage.completed
|
||||
? QuestStage.completed
|
||||
: QuestStage.paused,
|
||||
goal: cardGoal,
|
||||
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
||||
progress: type.gathers ? (done ? goal : 0) : count,
|
||||
progress: type.gathers ? (done ? goal : walked ?? 0) : count,
|
||||
rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy),
|
||||
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
|
||||
participants: quest.intOr('participantCount'),
|
||||
@@ -249,7 +294,15 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
.get()
|
||||
.then((s) => s.data() ?? const <String, dynamic>{});
|
||||
final submission = await _submission(questId, quest);
|
||||
final card = _card(questId, quest, progress, uid, await _policy(), submission);
|
||||
final card = _card(
|
||||
questId,
|
||||
quest,
|
||||
progress,
|
||||
uid,
|
||||
await _policy(),
|
||||
submission,
|
||||
await _walkFor(quest, progress),
|
||||
);
|
||||
final link = quest['link'];
|
||||
final repeat = quest['repeat'] is Map ? Map<String, dynamic>.from(quest['repeat'] as Map) : null;
|
||||
final endsAt = quest['endsAt'];
|
||||
@@ -474,13 +527,64 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
'description': description,
|
||||
});
|
||||
|
||||
/// 시작·중단은 세션 상태다.
|
||||
/// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 원장 C-83).
|
||||
///
|
||||
/// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여
|
||||
/// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는
|
||||
/// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다.
|
||||
/// 진행 문서는 규칙상 서버 전용이라 함수로만 쓴다. 보상 상태는 여전히 [verify] 가 지급에 성공한 뒤
|
||||
/// 서버 카운터로 확정되고, 지급되면 서버가 세션을 지운다.
|
||||
@override
|
||||
Future<QuestStage> advance(String questId, QuestStage next) async => next;
|
||||
Future<QuestStage> advance(
|
||||
String questId,
|
||||
QuestStage next, {
|
||||
QuestSession? session,
|
||||
}) async {
|
||||
final state = switch (next) {
|
||||
QuestStage.inProgress => 'inProgress',
|
||||
QuestStage.paused => 'paused',
|
||||
QuestStage.completed => 'completed',
|
||||
_ => 'none',
|
||||
};
|
||||
await _backend.call('fnQuestProgress', {
|
||||
'questId': questId,
|
||||
'state': state,
|
||||
if (session != null && state != 'none') ...{
|
||||
'progress': session.progress,
|
||||
'total': session.total,
|
||||
'positionMs': session.position.inMilliseconds,
|
||||
if (session.hits.isNotEmpty) 'hits': session.hits,
|
||||
'judgement': ?session.judgement,
|
||||
},
|
||||
});
|
||||
questSessionSignal.value++;
|
||||
return next;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<QuestCard?> pausedQuest() async {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
final snap = await db
|
||||
.collection('users/$uid/questProgress')
|
||||
.where('session.state', isEqualTo: 'paused')
|
||||
.get();
|
||||
// 가장 최근 1건(QA 3-34). 한 사람이 동시에 멈춘 Quest 는 몇 건이라 단말에서 고른다.
|
||||
final docs = [...snap.docs]
|
||||
..sort((a, b) {
|
||||
DateTime at(Map<String, dynamic> d) {
|
||||
final t = (d['session'] as Map?)?['updatedAt'];
|
||||
return t is Timestamp ? t.toDate() : DateTime(0);
|
||||
}
|
||||
|
||||
return at(b.data()).compareTo(at(a.data()));
|
||||
});
|
||||
final policy = await _policy();
|
||||
for (final doc in docs) {
|
||||
final quest = (await db.doc('quests/${doc.id}').get()).data();
|
||||
if (quest == null || !_open(quest, ServerClock.now(), uid)) continue;
|
||||
final card = _card(doc.id, quest, doc.data(), uid, policy);
|
||||
if (card.stage == QuestStage.paused) return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은
|
||||
/// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜
|
||||
@@ -643,6 +747,14 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
|
||||
if (_walkSteps <= 0) {
|
||||
// 목록에서 바로 「보상받기」(진행 화면을 거치지 않음) — 걸음을 한 번 읽는다.
|
||||
try {
|
||||
await _steps().first.timeout(const Duration(seconds: 5));
|
||||
} on Exception {
|
||||
// 아래에서 「세지 못함」으로 끊는다.
|
||||
}
|
||||
}
|
||||
if (_walkSteps <= 0) {
|
||||
// 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만,
|
||||
// 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다.
|
||||
@@ -687,7 +799,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
||||
final run = _shakeRun;
|
||||
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
|
||||
// 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(원장 C-83).
|
||||
final saved = card.session?.state == QuestStage.completed ? card.session : null;
|
||||
final judgement = run?.judgement() ?? saved?.judgement;
|
||||
final segments = run?.segments ?? saved?.total ?? 0;
|
||||
if (judgement == null || segments <= 0) {
|
||||
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'];
|
||||
@@ -700,8 +818,8 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
return _backend.call('fnQuestVerifyMusicShake', {
|
||||
'questId': card.id,
|
||||
'trackId': trackId,
|
||||
'totalSegments': run.segments,
|
||||
'judgement': run.judgement(),
|
||||
'totalSegments': segments,
|
||||
'judgement': judgement,
|
||||
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
||||
...await _fandomField(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user