Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리

This commit is contained in:
Sn-Kinos
2026-10-06 12:23:47 +09:00
parent 4e8d989f29
commit 14fef64999
24 changed files with 300 additions and 34 deletions
@@ -178,6 +178,8 @@ export const fnQuestProgress = onCall(async (req) => {
total: count(data, 'total', 1_000_000), total: count(data, 'total', 1_000_000),
positionMs: count(data, 'positionMs', 86_400_000), positionMs: count(data, 'positionMs', 86_400_000),
...(hits !== undefined ? { hits } : {}), ...(hits !== undefined ? { hits } : {}),
// 판을 시작할 때의 첫 박 — 운영값이 바뀌면 앱이 옛 일시 정지를 버리고 새 판으로 시작한다.
...(typeof data.firstBeatMs === 'number' ? { firstBeatMs: count(data, 'firstBeatMs', 86_400_000) } : {}),
...(tally ? { judgement: tally } : {}), ...(tally ? { judgement: tally } : {}),
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
+8 -1
View File
@@ -119,6 +119,13 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
if (typeof questSegments === 'number' && questSegments !== totalSegments) { if (typeof questSegments === 'number' && questSegments !== totalSegments) {
fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.'); fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
} }
// 첫 박 시각(영상 시각 기준 박 위치 `firstBeatMs + i·60/bpm`)도 같은 기준이어야 한다.
const questFirstBeat = quest.get('firstBeatMs');
const firstBeatMs = typeof questFirstBeat === 'number' ? questFirstBeat : 0;
const sentFirstBeat = req.data?.firstBeatMs;
if (sentFirstBeat !== undefined && sentFirstBeat !== firstBeatMs) {
fail(ERR.questAbnormal, '첫 박 시각이 퀘스트 정의와 다릅니다.');
}
const limit = await dailyLimitOf(questId); const limit = await dailyLimitOf(questId);
const state = await progress(uid, questId, key); const state = await progress(uid, questId, key);
if (state.count >= limit) { if (state.count >= limit) {
@@ -134,7 +141,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
type: 'musicShake', type: 'musicShake',
fandomId: optionalStr(req.data, 'fandomId') ?? '', fandomId: optionalStr(req.data, 'fandomId') ?? '',
value: judgement.perfect, value: judgement.perfect,
evidence: { musicShake: { trackId, totalSegments, judgements: judgement } }, evidence: { musicShake: { trackId, totalSegments, firstBeatMs, judgements: judgement } },
questId, questId,
verified: grade !== 'F', verified: grade !== 'F',
rejectReason: grade === 'F' ? ERR.questIncomplete : null, rejectReason: grade === 'F' ? ERR.questIncomplete : null,
@@ -321,10 +321,10 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => { it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
await db().doc('quests/music-e2e').set({ await db().doc('quests/music-e2e').set({
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1, type: 'musicShake', trackId: 't1', totalSegments: 10, firstBeatMs: 17_000, dailyLimit: 1,
}); });
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, { const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
questId: 'music-e2e', trackId, totalSegments: 10, questId: 'music-e2e', trackId, totalSegments: 10, firstBeatMs: 17_000,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra, judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
}); });
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01'); expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
@@ -332,11 +332,15 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
expect(await codeOf(() => shake('t1', { expect(await codeOf(() => shake('t1', {
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 }, totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
}))).toBe('E-QST-02'); }))).toBe('E-QST-02');
// 첫 박 시각이 퀘스트 정의와 다르면 판정표를 받지 않는다.
expect(await codeOf(() => shake('t1', { firstBeatMs: 0 }))).toBe('E-QST-02');
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다. // 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
await Promise.allSettled([shake('t1'), shake('t1')]); await Promise.allSettled([shake('t1'), shake('t1')]);
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03'); expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get(); const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1); expect(ledger.size).toBe(1);
const logs = await db().collection(`users/${UID}/activityLogs`).where('questId', '==', 'music-e2e').get();
expect(logs.docs[0]!.get('evidence.musicShake.firstBeatMs')).toBe(17_000);
}); });
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */ /** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
@@ -420,6 +420,7 @@ class FirestoreHomeRepository implements HomeRepository {
: FirestoreQuestRepository.pausedSessionOf( : FirestoreQuestRepository.pausedSessionOf(
QuestType.parse(quest.strOr('type')), QuestType.parse(quest.strOr('type')),
progress, progress,
quest,
), ),
rewardPop: rewardPop:
openTimeAttack(quest)?.rewardPop ?? openTimeAttack(quest)?.rewardPop ??
@@ -34,6 +34,7 @@ class MusicShakePlayScreen extends StatefulWidget {
required this.shakes, required this.shakes,
this.player, this.player,
this.judge, this.judge,
this.countdown = const Duration(seconds: 3),
}); });
final MusicShakeSetup setup; final MusicShakeSetup setup;
@@ -42,6 +43,10 @@ class MusicShakePlayScreen extends StatefulWidget {
final ShakeJudge? judge; final ShakeJudge? judge;
final Stream<DateTime> shakes; final Stream<DateTime> shakes;
/// 재생을 시작(이어하기 포함)하면 영상을 멈춰 두고 세는 3·2·1 — 이클립스 오버레이 카운트다운
/// (사본 `373:444976`)과 같다. Figma 에 Music Shake 카운트다운 화면은 없다(대표 지시 · 확인 통보).
final Duration countdown;
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player; final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
@@ -55,10 +60,16 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
ShakeJudge( ShakeJudge(
bpm: widget.setup.bpm, bpm: widget.setup.bpm,
segments: widget.setup.segments, segments: widget.setup.segments,
firstBeat: widget.setup.firstBeat,
windows: widget.setup.windows, windows: widget.setup.windows,
); );
StreamSubscription<DateTime>? _sub; StreamSubscription<DateTime>? _sub;
Timer? _tick; Timer? _tick;
final _control = ShakePlayerControl();
/// 카운트다운 — 한 번 세었으면 true, 세는 중이면 [_countEnd] 가 있다.
bool _counted = false;
DateTime? _countEnd;
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각. /// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
bool _playing = false; bool _playing = false;
@@ -89,6 +100,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
_playing ? _anchorMedia + wall.difference(_anchorWall) : null; _playing ? _anchorMedia + wall.difference(_anchorWall) : null;
void _onEvent(ShakePlayerEvent e) { void _onEvent(ShakePlayerEvent e) {
if (e.playing && !_counted && widget.countdown > Duration.zero) {
// 처음 재생(이어하기 포함)이 시작되면 영상을 멈춰 두고 센다 — 세는 동안은 판정하지 않는다.
_counted = true;
_countEnd = DateTime.now().add(widget.countdown);
_control.pauseAt(_judge.position);
if (mounted) setState(() {});
}
if (_countEnd != null) {
_playing = false;
return;
}
if (_playing != e.playing && mounted) setState(() {}); if (_playing != e.playing && mounted) setState(() {});
_playing = e.playing; _playing = e.playing;
_anchorMedia = e.position; _anchorMedia = e.position;
@@ -98,6 +120,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
} }
void _onTick() { void _onTick() {
final end = _countEnd;
if (end != null) {
if (DateTime.now().isBefore(end)) {
setState(() {});
} else {
_countEnd = null;
_control.play();
setState(() {});
}
return;
}
final now = _mediaAt(DateTime.now()); final now = _mediaAt(DateTime.now());
if (now == null || _done) return; if (now == null || _done) return;
final judged = _judge.live(now).judged; final judged = _judge.live(now).judged;
@@ -120,6 +153,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final now = _mediaAt(DateTime.now()) ?? _anchorMedia; final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
final v = _judge.live(now); final v = _judge.live(now);
final left = _countEnd?.difference(DateTime.now());
return Scaffold( return Scaffold(
backgroundColor: AppColors.cosmicVoid, backgroundColor: AppColors.cosmicVoid,
appBar: AppBar( appBar: AppBar(
@@ -138,19 +172,21 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
switch (v.last) { left != null
ShakeHit.perfect => 'PERFECT', ? '${(left.inMilliseconds / 1000).ceil().clamp(1, 99)}'
ShakeHit.good => 'GOOD', : switch (v.last) {
ShakeHit.miss => 'MISS', ShakeHit.perfect => 'PERFECT',
null => ' ', ShakeHit.good => 'GOOD',
}, ShakeHit.miss => 'MISS',
null => ' ',
},
key: const ValueKey('shake-last'), key: const ValueKey('shake-last'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 34, fontSize: left != null ? 62 : 34,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
height: 1.3, height: left != null ? 1.4 : 1.3,
color: switch (v.last) { color: left != null ? AppColors.onCosmic : switch (v.last) {
ShakeHit.perfect => AppColors.cosmicAccent, ShakeHit.perfect => AppColors.cosmicAccent,
ShakeHit.good => const Color(0xFF67D3A9), ShakeHit.good => const Color(0xFF67D3A9),
_ => const Color(0xFF777777), _ => const Color(0xFF777777),
@@ -201,9 +237,21 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
videoId: widget.setup.videoId, videoId: widget.setup.videoId,
onEvent: onEvent, onEvent: onEvent,
start: _judge.position, start: _judge.position,
control: _control,
); );
} }
/// 플레이어를 밖에서 멈추고 켠다 — 카운트다운 동안 영상을 세워 두는 데 쓴다.
/// 플레이어가 붙기 전(테스트의 가짜 플레이어 포함)에는 아무 일도 하지 않는다.
class ShakePlayerControl {
void Function(String js)? _run;
void pauseAt(Duration at) =>
_run?.call('player.seekTo(${at.inMilliseconds / 1000},true);player.pauseVideo();');
void play() => _run?.call('player.playVideo();');
}
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다. /// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
class ShakeTally extends StatelessWidget { class ShakeTally extends StatelessWidget {
const ShakeTally({ const ShakeTally({
@@ -275,9 +323,11 @@ class YoutubeEmbed extends StatefulWidget {
required this.videoId, required this.videoId,
required this.onEvent, required this.onEvent,
this.start = Duration.zero, this.start = Duration.zero,
this.control,
}); });
final String videoId; final String videoId;
final ShakePlayerControl? control;
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다. /// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
final Duration start; final Duration start;
@@ -340,6 +390,7 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
if (web.platform case final AndroidWebViewController android) { if (web.platform case final AndroidWebViewController android) {
unawaited(android.setMediaPlaybackRequiresUserGesture(false)); unawaited(android.setMediaPlaybackRequiresUserGesture(false));
} }
widget.control?._run = (js) => unawaited(web.runJavaScript(js));
unawaited( unawaited(
web.loadHtmlString( web.loadHtmlString(
YoutubeEmbed.html( YoutubeEmbed.html(
+30 -1
View File
@@ -370,6 +370,7 @@ class QuestSession {
this.position = Duration.zero, this.position = Duration.zero,
this.hits = const [], this.hits = const [],
this.judgement, this.judgement,
this.firstBeat,
}); });
/// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나. /// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나.
@@ -384,6 +385,16 @@ class QuestSession {
/// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점. /// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점.
final Duration position; final Duration position;
/// 이 일시 정지가 지금 운영값([setup])의 박 위치로 이어질 수 있는지. 첫 박이나 판 길이가 바뀌었으면 false.
bool fitsShake(MusicShakeSetup setup) => fits(setup.firstBeat, setup.segments);
/// [fitsShake] 와 같은 비교 — 운영값만 아는 곳(목록 · HOME · 캡슐은 퀘스트 문서를 읽는다)용.
bool fits(Duration firstBeat, int segments) =>
this.firstBeat == firstBeat && total == segments;
/// Music Shake 판을 시작할 때의 첫 박(`quests.firstBeatMs`). 없으면 첫 박 도입 전에 저장된 판이다.
final Duration? firstBeat;
/// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1. /// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1.
final List<double> hits; final List<double> hits;
@@ -414,6 +425,7 @@ class QuestCard {
this.publishState = '', this.publishState = '',
this.authorName = '', this.authorName = '',
this.session, this.session,
this.staleShake = false,
}); });
final String id; final String id;
@@ -423,6 +435,10 @@ class QuestCard {
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null. /// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
final QuestSession? session; final QuestSession? session;
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다.
/// 이때 [session] 은 null 이고 카드는 「시작하기」로 보인다 — 시작하면 안내를 한 번 띄운다.
final bool staleShake;
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다. /// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다. /// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
final String authorName; final String authorName;
@@ -473,6 +489,7 @@ class QuestCard {
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다. /// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard( QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
session: session, session: session,
staleShake: staleShake,
id: id, id: id,
title: title, title: title,
categoryName: categoryName, categoryName: categoryName,
@@ -500,7 +517,9 @@ class MusicShakeSetup {
required this.title, required this.title,
required this.bpm, required this.bpm,
required this.segments, required this.segments,
this.firstBeat = Duration.zero,
this.windows = const ShakeWindows(), this.windows = const ShakeWindows(),
this.leadIn = const Duration(seconds: 3),
}); });
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID. /// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
@@ -512,9 +531,19 @@ class MusicShakeSetup {
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02). /// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
final double bpm; final double bpm;
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02). /// `quests.totalSegments` — 판정 박 수. 곡 전체를 덮도록 운영자가 정한다. 🟡 회신 대기(Q-MS-02).
final int segments; final int segments;
/// `quests.firstBeatMs` — 영상에서 첫 박 시각. 🟡 임시값 — 회신 대기(Q-MS-02 · Q-MS-03). 없으면 0.
final Duration firstBeat;
final ShakeWindows windows; final ShakeWindows windows;
/// 첫 박 몇 초 전부터 영상을 트는가 — `config/policy.musicShake.leadInMs`(없으면 3초).
/// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」 B안).
final Duration leadIn;
/// 새 판의 영상 시작 시각 — 첫 박 [leadIn] 전(0 아래로는 가지 않는다). 이어하기는 저장 위치다.
Duration get startAt => firstBeat > leadIn ? firstBeat - leadIn : Duration.zero;
} }
class QuestDetail { class QuestDetail {
@@ -850,12 +850,19 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
return; return;
} }
if (!mounted) return; if (!mounted) return;
// 운영값(첫 박 · 판 길이)이 바뀐 뒤의 옛 일시 정지는 박 위치가 맞지 않는다 — 버리고 새 판으로 시작한다.
// 목록 · 상세가 이미 시작하기로 보이는 경우(staleShake)도 같은 안내를 한 번 한다.
if (detail.card.staleShake || (resume != null && !resume.fitsShake(setup))) {
resume = null;
_toast(context.l10n.questShakeRestart);
}
final judge = ShakeJudge( final judge = ShakeJudge(
bpm: setup.bpm, bpm: setup.bpm,
segments: setup.segments, segments: setup.segments,
firstBeat: setup.firstBeat,
windows: setup.windows, windows: setup.windows,
hits: resume?.hits ?? const [], hits: resume?.hits ?? const [],
position: resume?.position ?? Duration.zero, position: resume?.position ?? setup.startAt,
); );
QuestSession snapshot(QuestStage state) => QuestSession( QuestSession snapshot(QuestStage state) => QuestSession(
state: state, state: state,
@@ -863,6 +870,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
total: setup.segments, total: setup.segments,
position: judge.position, position: judge.position,
hits: judge.hits, hits: judge.hits,
firstBeat: setup.firstBeat,
); );
setState(() => _stage = QuestStage.inProgress); setState(() => _stage = QuestStage.inProgress);
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다. // 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
@@ -900,6 +908,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
state: QuestStage.completed, state: QuestStage.completed,
progress: setup.segments, progress: setup.segments,
total: setup.segments, total: setup.segments,
firstBeat: setup.firstBeat,
judgement: run.judgement(), judgement: run.judgement(),
), ),
); );
+16 -6
View File
@@ -150,7 +150,7 @@ typedef ShakeLive = ({
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24). /// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
/// ///
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라 /// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박 /// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good] /// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간 /// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
/// 수) 서버가 끊는다. /// 수) 서버가 끊는다.
@@ -158,6 +158,7 @@ class ShakeJudge {
ShakeJudge({ ShakeJudge({
required this.bpm, required this.bpm,
required this.segments, required this.segments,
this.firstBeat = Duration.zero,
this.windows = const ShakeWindows(), this.windows = const ShakeWindows(),
List<double> hits = const [], List<double> hits = const [],
this.position = Duration.zero, this.position = Duration.zero,
@@ -170,6 +171,9 @@ class ShakeJudge {
final double bpm; final double bpm;
final int segments; final int segments;
/// `quests.firstBeatMs` — 영상에서 첫 박이 오는 시각. 박 i 는 `firstBeat + i * 60/bpm` 이다.
final Duration firstBeat;
final ShakeWindows windows; final ShakeWindows windows;
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다. /// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
@@ -184,13 +188,19 @@ class ShakeJudge {
Duration get beat => Duration(microseconds: (60e6 / bpm).round()); Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 박의 판정 창이 닫히는 영상 시각. /// 마지막 박의 판정 창이 닫히는 영상 시각.
Duration get end => Duration( Duration get end =>
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(), firstBeat +
); Duration(
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
);
/// 영상 시각 [at] 이 첫 박에서 몇 박 떨어졌나.
double _beats(Duration at) =>
(at - firstBeat).inMicroseconds / beat.inMicroseconds;
/// 영상 시각 [at] 의 흔들기 한 번. /// 영상 시각 [at] 의 흔들기 한 번.
void add(Duration at) { void add(Duration at) {
final pos = at.inMicroseconds / beat.inMicroseconds; final pos = _beats(at);
final i = pos.round(); final i = pos.round();
if (i < 0 || i >= segments) return; if (i < 0 || i >= segments) return;
final d = (pos - i).abs(); final d = (pos - i).abs();
@@ -203,7 +213,7 @@ class ShakeJudge {
ShakeLive live(Duration now) { ShakeLive live(Duration now) {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0; var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
ShakeHit? last; ShakeHit? last;
final pos = now.inMicroseconds / beat.inMicroseconds; final pos = _beats(now);
for (var i = 0; i < segments && i + windows.good <= pos; i++) { for (var i = 0; i < segments && i + windows.good <= pos; i++) {
judged++; judged++;
final d = _best[i]; final d = _best[i];
@@ -158,13 +158,26 @@ class FirestoreQuestRepository implements QuestRepository {
/// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진 /// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진
/// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null. /// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null.
static QuestSession? pausedSessionOf(QuestType type, Map<String, dynamic> progress) { static QuestSession? pausedSessionOf(
QuestType type,
Map<String, dynamic> progress,
Map<String, dynamic> quest,
) {
final s = sessionOf(progress); final s = sessionOf(progress);
return s == null || type == QuestType.steps || s.state == QuestStage.completed return s == null ||
type == QuestType.steps ||
s.state == QuestStage.completed ||
staleShake(type, quest, s)
? null ? null
: s; : s;
} }
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션인가 — [QuestSession.fitsShake] 와 같은 기준.
static bool staleShake(QuestType type, Map<String, dynamic> quest, QuestSession? s) =>
type == QuestType.musicShake &&
s != null &&
!s.fits(Duration(milliseconds: quest.intOr('firstBeatMs')), quest.intOr('totalSegments'));
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83). /// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
static QuestSession? sessionOf(Map<String, dynamic> progress) { static QuestSession? sessionOf(Map<String, dynamic> progress) {
final raw = progress['session']; final raw = progress['session'];
@@ -185,6 +198,7 @@ class FirestoreQuestRepository implements QuestRepository {
total: m.intOr('total'), total: m.intOr('total'),
position: Duration(milliseconds: m.intOr('positionMs')), position: Duration(milliseconds: m.intOr('positionMs')),
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [], hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
firstBeat: m['firstBeatMs'] is num ? Duration(milliseconds: m.intOr('firstBeatMs')) : null,
judgement: judgement is Map judgement: judgement is Map
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()} ? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
: null, : null,
@@ -238,13 +252,17 @@ class FirestoreQuestRepository implements QuestRepository {
: type == QuestType.musicShake : type == QuestType.musicShake
? quest.intOr('bpm') ? quest.intOr('bpm')
: dailyLimit; : dailyLimit;
final session = sessionOf(progress); final saved = sessionOf(progress);
// 이어갈 수 없는 옛 Music Shake 판은 세션이 없는 것으로 본다 — 「시작하기」로 보이고 시작 때 안내한다.
final stale = staleShake(type, quest, saved);
final session = stale ? null : saved;
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1); final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값. // 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
final walked = steps && session != null ? walk ?? session.progress : null; final walked = steps && session != null ? walk ?? session.progress : null;
return QuestCard( return QuestCard(
session: session, session: session,
staleShake: stale,
id: id, id: id,
title: questTitle(quest), title: questTitle(quest),
categoryName: type.category, categoryName: type.category,
@@ -561,6 +579,7 @@ class FirestoreQuestRepository implements QuestRepository {
'total': session.total, 'total': session.total,
'positionMs': session.position.inMilliseconds, 'positionMs': session.position.inMilliseconds,
if (session.hits.isNotEmpty) 'hits': session.hits, if (session.hits.isNotEmpty) 'hits': session.hits,
if (session.firstBeat != null) 'firstBeatMs': session.firstBeat!.inMilliseconds,
'judgement': ?session.judgement, 'judgement': ?session.judgement,
}, },
}); });
@@ -792,12 +811,18 @@ class FirestoreQuestRepository implements QuestRepository {
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) { if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable); throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
} }
final policy = await _policy();
final shake = policy['musicShake'];
// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」): 첫 박 3초 전부터 튼다.
final leadIn = shake is Map ? shake['leadInMs'] : null;
return MusicShakeSetup( return MusicShakeSetup(
videoId: videoId, videoId: videoId,
title: link.strOr('title', questTitle(quest)), title: link.strOr('title', questTitle(quest)),
bpm: bpm.toDouble(), bpm: bpm.toDouble(),
segments: segments, segments: segments,
windows: ShakeWindows.from(await _policy()), firstBeat: Duration(milliseconds: quest.intOr('firstBeatMs')),
windows: ShakeWindows.from(policy),
leadIn: Duration(milliseconds: leadIn is num && leadIn >= 0 ? leadIn.round() : 3000),
); );
} }
@@ -829,6 +854,7 @@ class FirestoreQuestRepository implements QuestRepository {
'questId': card.id, 'questId': card.id,
'trackId': trackId, 'trackId': trackId,
'totalSegments': segments, 'totalSegments': segments,
if (run != null) 'firstBeatMs': run.firstBeat.inMilliseconds,
'judgement': judgement, 'judgement': judgement,
'occurredAt': DateTime.now().toUtc().toIso8601String(), 'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(), ...await _fandomField(),
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "Details", "questDetails": "Details",
"questInProgress": "In progress", "questInProgress": "In progress",
"questResume": "Resume", "questResume": "Resume",
"questShakeRestart": "The quest settings changed, so you will start from the beginning.",
"questPaused": "Paused", "questPaused": "Paused",
"questTypeAttend": "Check-in", "questTypeAttend": "Check-in",
"questTypeSteps": "Walking", "questTypeSteps": "Walking",
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "Detalles", "questDetails": "Detalles",
"questInProgress": "En curso", "questInProgress": "En curso",
"questResume": "Reanudar", "questResume": "Reanudar",
"questShakeRestart": "La configuración de la misión cambió, así que empiezas desde el principio.",
"questPaused": "En pausa", "questPaused": "En pausa",
"questTypeAttend": "Asistencia", "questTypeAttend": "Asistencia",
"questTypeSteps": "Caminar", "questTypeSteps": "Caminar",
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "詳細", "questDetails": "詳細",
"questInProgress": "進行中", "questInProgress": "進行中",
"questResume": "再開する", "questResume": "再開する",
"questShakeRestart": "クエストの設定が変わったため、最初から始めます",
"questPaused": "一時停止", "questPaused": "一時停止",
"questTypeAttend": "出席", "questTypeAttend": "出席",
"questTypeSteps": "ウォーキング", "questTypeSteps": "ウォーキング",
+1
View File
@@ -1163,6 +1163,7 @@
"questDetails": "자세히", "questDetails": "자세히",
"questInProgress": "진행중", "questInProgress": "진행중",
"questResume": "이어하기", "questResume": "이어하기",
"questShakeRestart": "퀘스트 설정이 바뀌어 처음부터 시작합니다",
"questPaused": "일시 정지", "questPaused": "일시 정지",
"questTypeAttend": "출석", "questTypeAttend": "출석",
"questTypeSteps": "걷기", "questTypeSteps": "걷기",
+6
View File
@@ -3620,6 +3620,12 @@ abstract class AppLocalizations {
/// **'이어하기'** /// **'이어하기'**
String get questResume; String get questResume;
/// No description provided for @questShakeRestart.
///
/// In ko, this message translates to:
/// **'퀘스트 설정이 바뀌어 처음부터 시작합니다'**
String get questShakeRestart;
/// No description provided for @questPaused. /// No description provided for @questPaused.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
+4
View File
@@ -2036,6 +2036,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get questResume => 'Resume'; String get questResume => 'Resume';
@override
String get questShakeRestart =>
'The quest settings changed, so you will start from the beginning.';
@override @override
String get questPaused => 'Paused'; String get questPaused => 'Paused';
+4
View File
@@ -2048,6 +2048,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get questResume => 'Reanudar'; String get questResume => 'Reanudar';
@override
String get questShakeRestart =>
'La configuración de la misión cambió, así que empiezas desde el principio.';
@override @override
String get questPaused => 'En pausa'; String get questPaused => 'En pausa';
+3
View File
@@ -1976,6 +1976,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get questResume => '再開する'; String get questResume => '再開する';
@override
String get questShakeRestart => 'クエストの設定が変わったため、最初から始めます';
@override @override
String get questPaused => '一時停止'; String get questPaused => '一時停止';
+3
View File
@@ -1977,6 +1977,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get questResume => '이어하기'; String get questResume => '이어하기';
@override
String get questShakeRestart => '퀘스트 설정이 바뀌어 처음부터 시작합니다';
@override @override
String get questPaused => '일시 정지'; String get questPaused => '일시 정지';
+3
View File
@@ -1974,6 +1974,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get questResume => '继续'; String get questResume => '继续';
@override
String get questShakeRestart => '任务设置已更改,将从头开始';
@override @override
String get questPaused => '已暂停'; String get questPaused => '已暂停';
+1
View File
@@ -586,6 +586,7 @@
"questDetails": "详情", "questDetails": "详情",
"questInProgress": "进行中", "questInProgress": "进行中",
"questResume": "继续", "questResume": "继续",
"questShakeRestart": "任务设置已更改,将从头开始",
"questPaused": "已暂停", "questPaused": "已暂停",
"questTypeAttend": "签到", "questTypeAttend": "签到",
"questTypeSteps": "步行", "questTypeSteps": "步行",
@@ -2158,6 +2158,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied; String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
@override
String get questShakeRestart => _remote['questShakeRestart'] ?? _base.questShakeRestart;
@override @override
String get questStart => _remote['questStart'] ?? _base.questStart; String get questStart => _remote['questStart'] ?? _base.questStart;
+2 -2
View File
@@ -263,10 +263,10 @@ void main() {
test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async { test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async {
final db = await _seed(); final db = await _seed();
await db.doc('quests/shake').set({ await db.doc('quests/shake').set({
'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake', 'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake', 'totalSegments': 32,
}); });
await db.doc('users/$_uid/questProgress/shake').set({ await db.doc('users/$_uid/questProgress/shake').set({
'session': {'state': 'paused', 'progress': 14, 'total': 32}, 'session': {'state': 'paused', 'progress': 14, 'total': 32, 'firstBeatMs': 0},
}); });
final quest = (await FirestoreHomeRepository(_backend(db)).load()).quests.single; final quest = (await FirestoreHomeRepository(_backend(db)).load()).quests.single;
expect( expect(
+76
View File
@@ -19,6 +19,36 @@ void main() {
segments: 3, segments: 3,
); );
test('첫 박 · 판 길이가 바뀐 뒤의 옛 일시 정지는 이어하지 않는다 (10/6 대표 지적)', () {
const fresh = MusicShakeSetup(
videoId: 'v', title: 't', bpm: 114, segments: 368,
firstBeat: Duration(milliseconds: 24276),
);
// 첫 박 도입 전(32박, 첫 박 0)에 저장된 판.
const old = QuestSession(state: QuestStage.paused, progress: 19, total: 32);
expect(old.fitsShake(fresh), isFalse);
const same = QuestSession(
state: QuestStage.paused, progress: 19, total: 368,
firstBeat: Duration(milliseconds: 24276),
);
expect(same.fitsShake(fresh), isTrue);
});
test('새 판은 첫 박 leadIn 전부터 틀고, 첫 박이 더 이르면 0 이다 (10/6 B안)', () {
const s = MusicShakeSetup(
videoId: 'v', title: 't', bpm: 114, segments: 368,
firstBeat: Duration(milliseconds: 24276),
);
expect(s.startAt, const Duration(milliseconds: 21276));
expect(
const MusicShakeSetup(
videoId: 'v', title: 't', bpm: 60, segments: 3,
firstBeat: Duration(seconds: 2),
).startAt,
Duration.zero,
);
});
testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async { testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async {
final shakes = StreamController<DateTime>(); final shakes = StreamController<DateTime>();
addTearDown(shakes.close); addTearDown(shakes.close);
@@ -35,6 +65,7 @@ void main() {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => MusicShakePlayScreen( builder: (_) => MusicShakePlayScreen(
setup: setup, setup: setup,
countdown: Duration.zero,
shakes: shakes.stream, shakes: shakes.stream,
player: (onEvent) { player: (onEvent) {
player = onEvent; player = onEvent;
@@ -141,6 +172,7 @@ void main() {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => MusicShakePlayScreen( builder: (_) => MusicShakePlayScreen(
setup: setup, setup: setup,
countdown: Duration.zero,
shakes: shakes.stream, shakes: shakes.stream,
judge: judge, judge: judge,
player: (onEvent) { player: (onEvent) {
@@ -181,4 +213,48 @@ void main() {
expect(resume, contains('player.seekTo(9.91,true);player.playVideo();')); expect(resume, contains('player.seekTo(9.91,true);player.playVideo();'));
expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo'))); expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo')));
}); });
testWidgets('재생을 시작하면 3·2·1 을 세는 동안 판정하지 않고, 0 이 되면 판정이 시작된다', (tester) async {
final shakes = StreamController<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> player;
final judge = ShakeJudge(bpm: 60, segments: 3);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: MusicShakePlayScreen(
setup: setup,
countdown: const Duration(milliseconds: 500), // 화면은 단말 시계로 센다
shakes: shakes.stream,
judge: judge,
player: (onEvent) {
player = onEvent;
return const SizedBox();
},
),
),
);
player((playing: true, position: Duration.zero, ended: false));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('1'), findsOneWidget);
shakes.add(DateTime.now()); // 세는 동안의 흔들기는 세지 않는다.
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 700)));
await tester.pump(const Duration(milliseconds: 60));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('1'), findsNothing);
player((playing: true, position: Duration.zero, ended: false));
shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 60));
expect(judge.hits[0], inInclusiveRange(0, 0.01));
await tester.pumpWidget(const SizedBox());
});
test('박은 firstBeat 부터 — 인트로의 흔들기는 박이 아니다', () {
final j = ShakeJudge(bpm: 60, segments: 2, firstBeat: const Duration(seconds: 17))
..add(const Duration(seconds: 1))
..add(const Duration(seconds: 17))
..add(const Duration(milliseconds: 18300));
expect(j.end, const Duration(milliseconds: 18500));
expect(j.judgement(), {'perfect': 1, 'good': 1, 'miss': 0, 'maxCombo': 2});
});
} }
+28 -8
View File
@@ -408,7 +408,7 @@ service cloud.firestore {
}, SetOptions(merge: true)); }, SetOptions(merge: true));
await db.doc('quests/shake').set({ await db.doc('quests/shake').set({
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1, 'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
'totalSegments': 3, 'bpm': 120, 'totalSegments': 3, 'bpm': 120, 'firstBeatMs': 17000,
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'}, 'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
}); });
final backend = _RecordingBackend(db); final backend = _RecordingBackend(db);
@@ -423,20 +423,22 @@ service cloud.firestore {
expect(setup.videoId, 'WMweEpGlu_U'); expect(setup.videoId, 'WMweEpGlu_U');
expect(setup.title, 'BTS - Butter'); expect(setup.title, 'BTS - Butter');
expect((setup.bpm, setup.segments), (120.0, 3)); expect((setup.bpm, setup.segments), (120.0, 3));
expect(setup.firstBeat, const Duration(seconds: 17));
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4)); expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
// 판을 끝내기 전에는 서버를 부르지 않는다. // 판을 끝내기 전에는 서버를 부르지 않는다.
await expectLater(repo.verify(card), throwsA(isA<BackendException>())); await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty); expect(backend.calls, isEmpty);
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows) repo.recordShake(ShakeJudge(bpm: 120, segments: 3, firstBeat: setup.firstBeat, windows: setup.windows)
..add(Duration.zero)); ..add(setup.firstBeat));
await repo.verify(card); await repo.verify(card);
final call = backend.calls.single; final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyMusicShake'); expect(call.name, 'fnQuestVerifyMusicShake');
expect(call.payload['questId'], 'shake'); expect(call.payload['questId'], 'shake');
expect(call.payload['trackId'], 'WMweEpGlu_U'); expect(call.payload['trackId'], 'WMweEpGlu_U');
expect(call.payload['totalSegments'], 3); expect(call.payload['totalSegments'], 3);
expect(call.payload['firstBeatMs'], 17000);
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1}); expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
}); });
@@ -661,7 +663,7 @@ service cloud.firestore {
final ref = db.doc('users/$_uid/questProgress/shake'); final ref = db.doc('users/$_uid/questProgress/shake');
final repo = FirestoreQuestRepository(_RecordingBackend(db)); final repo = FirestoreQuestRepository(_RecordingBackend(db));
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60}}); await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60, 'firstBeatMs': 0}});
var card = _card(await repo.load(), 'shake'); var card = _card(await repo.load(), 'shake');
// 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다. // 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다.
expect(card.stage, QuestStage.paused); expect(card.stage, QuestStage.paused);
@@ -669,7 +671,7 @@ service cloud.firestore {
expect(QuestTab.inProgress.accepts(card), isTrue); expect(QuestTab.inProgress.accepts(card), isTrue);
await ref.set({'session': { await ref.set({'session': {
'state': 'completed', 'progress': 60, 'total': 60, 'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30}, 'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}}); }});
card = _card(await repo.load(), 'shake'); card = _card(await repo.load(), 'shake');
@@ -680,7 +682,7 @@ service cloud.firestore {
test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async { test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async {
final db = await shakeSeed(); final db = await shakeSeed();
await db.doc('users/$_uid/questProgress/shake').set({'session': { await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'completed', 'progress': 60, 'total': 60, 'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30}, 'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}}); }});
final backend = _RecordingBackend(db); final backend = _RecordingBackend(db);
@@ -733,11 +735,11 @@ service cloud.firestore {
'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100, 'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100,
}); });
await db.doc('users/$_uid/questProgress/shake').set({'session': { await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60, 'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000), 'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000),
}}); }});
await db.doc('users/$_uid/questProgress/shake2').set({'session': { await db.doc('users/$_uid/questProgress/shake2').set({'session': {
'state': 'paused', 'progress': 3, 'total': 30, 'state': 'paused', 'progress': 3, 'total': 30, 'firstBeatMs': 0,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000), 'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000),
}}); }});
final repo = FirestoreQuestRepository(_RecordingBackend(db)); final repo = FirestoreQuestRepository(_RecordingBackend(db));
@@ -745,5 +747,23 @@ service cloud.firestore {
expect(card?.id, 'shake'); expect(card?.id, 'shake');
expect((card?.session?.progress, card?.session?.total), (43, 60)); expect((card?.session?.progress, card?.session?.total), (43, 60));
}); });
test('운영값이 바뀐 옛 Music Shake 판은 일시 정지로 보이지 않고 시작하기다 (10/6)', () async {
final db = await shakeSeed();
await db.doc('quests/shake').update({'firstBeatMs': 24276});
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
}});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
final card = _card(await repo.load(), 'shake');
expect((card.stage, card.session, card.staleShake), (QuestStage.notStarted, null, true));
expect(await repo.pausedQuest(), isNull);
// 맞는 판은 그대로 일시 정지다.
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 24276,
}});
final ok = _card(await repo.load(), 'shake');
expect((ok.stage, ok.staleShake), (QuestStage.paused, false));
});
}); });
} }