From 14fef64999334c1b48d33740ccc42b60750f112a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 12:23:47 +0900 Subject: [PATCH] =?UTF-8?q?Music=20Shake=20=EC=B2=AB=20=EB=B0=95=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=20=C2=B7=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=C2=B7=20=EC=98=9B=20=EC=9D=BC=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EC=A7=80=20=ED=8C=90=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/quest/quest.ts | 2 + app/firebase/functions/src/quest/verify.ts | 9 ++- app/firebase/functions/test/quest_e2e.test.ts | 8 +- .../home/home_repository_firestore.dart | 1 + .../quest/music_shake_play_screen.dart | 69 ++++++++++++++--- app/lib/features/quest/quest_data.dart | 31 +++++++- .../features/quest/quest_detail_screen.dart | 11 ++- app/lib/features/quest/quest_evidence.dart | 22 ++++-- .../quest/quest_repository_firestore.dart | 34 ++++++++- app/lib/l10n/app_en.arb | 1 + app/lib/l10n/app_es.arb | 1 + app/lib/l10n/app_ja.arb | 1 + app/lib/l10n/app_ko.arb | 1 + app/lib/l10n/app_localizations.dart | 6 ++ app/lib/l10n/app_localizations_en.dart | 4 + app/lib/l10n/app_localizations_es.dart | 4 + app/lib/l10n/app_localizations_ja.dart | 3 + app/lib/l10n/app_localizations_ko.dart | 3 + app/lib/l10n/app_localizations_zh.dart | 3 + app/lib/l10n/app_zh.arb | 1 + app/lib/l10n/remote_app_localizations.dart | 3 + app/test/backend_repository_test.dart | 4 +- app/test/music_shake_play_test.dart | 76 +++++++++++++++++++ app/test/quest_repository_test.dart | 36 +++++++-- 24 files changed, 300 insertions(+), 34 deletions(-) diff --git a/app/firebase/functions/src/quest/quest.ts b/app/firebase/functions/src/quest/quest.ts index bea9d45..ba4083c 100644 --- a/app/firebase/functions/src/quest/quest.ts +++ b/app/firebase/functions/src/quest/quest.ts @@ -178,6 +178,8 @@ export const fnQuestProgress = onCall(async (req) => { total: count(data, 'total', 1_000_000), positionMs: count(data, 'positionMs', 86_400_000), ...(hits !== undefined ? { hits } : {}), + // 판을 시작할 때의 첫 박 — 운영값이 바뀌면 앱이 옛 일시 정지를 버리고 새 판으로 시작한다. + ...(typeof data.firstBeatMs === 'number' ? { firstBeatMs: count(data, 'firstBeatMs', 86_400_000) } : {}), ...(tally ? { judgement: tally } : {}), updatedAt: FieldValue.serverTimestamp(), }, diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts index ac83745..ef39339 100644 --- a/app/firebase/functions/src/quest/verify.ts +++ b/app/firebase/functions/src/quest/verify.ts @@ -119,6 +119,13 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => { if (typeof questSegments === 'number' && questSegments !== totalSegments) { 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 state = await progress(uid, questId, key); if (state.count >= limit) { @@ -134,7 +141,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => { type: 'musicShake', fandomId: optionalStr(req.data, 'fandomId') ?? '', value: judgement.perfect, - evidence: { musicShake: { trackId, totalSegments, judgements: judgement } }, + evidence: { musicShake: { trackId, totalSegments, firstBeatMs, judgements: judgement } }, questId, verified: grade !== 'F', rejectReason: grade === 'F' ? ERR.questIncomplete : null, diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index d42001e..d75338d 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -321,10 +321,10 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리 it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => { 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 = {}) => 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, }); expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01'); @@ -332,11 +332,15 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리 expect(await codeOf(() => shake('t1', { totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 }, }))).toBe('E-QST-02'); + // 첫 박 시각이 퀘스트 정의와 다르면 판정표를 받지 않는다. + expect(await codeOf(() => shake('t1', { firstBeatMs: 0 }))).toBe('E-QST-02'); // 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다. await Promise.allSettled([shake('t1'), shake('t1')]); expect(await codeOf(() => shake('t1'))).toBe('E-POP-03'); const ledger = await db().collection(`users/${UID}/popTransactions`).get(); 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); }); /** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */ diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index d8ba81e..eb998d3 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -420,6 +420,7 @@ class FirestoreHomeRepository implements HomeRepository { : FirestoreQuestRepository.pausedSessionOf( QuestType.parse(quest.strOr('type')), progress, + quest, ), rewardPop: openTimeAttack(quest)?.rewardPop ?? diff --git a/app/lib/features/quest/music_shake_play_screen.dart b/app/lib/features/quest/music_shake_play_screen.dart index c4c25f9..27a7694 100644 --- a/app/lib/features/quest/music_shake_play_screen.dart +++ b/app/lib/features/quest/music_shake_play_screen.dart @@ -34,6 +34,7 @@ class MusicShakePlayScreen extends StatefulWidget { required this.shakes, this.player, this.judge, + this.countdown = const Duration(seconds: 3), }); final MusicShakeSetup setup; @@ -42,6 +43,10 @@ class MusicShakePlayScreen extends StatefulWidget { final ShakeJudge? judge; final Stream shakes; + /// 재생을 시작(이어하기 포함)하면 영상을 멈춰 두고 세는 3·2·1 — 이클립스 오버레이 카운트다운 + /// (사본 `373:444976`)과 같다. Figma 에 Music Shake 카운트다운 화면은 없다(대표 지시 · 확인 통보). + final Duration countdown; + /// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다. final Widget Function(ValueChanged onEvent)? player; @@ -55,10 +60,16 @@ class _MusicShakePlayScreenState extends State { ShakeJudge( bpm: widget.setup.bpm, segments: widget.setup.segments, + firstBeat: widget.setup.firstBeat, windows: widget.setup.windows, ); StreamSubscription? _sub; Timer? _tick; + final _control = ShakePlayerControl(); + + /// 카운트다운 — 한 번 세었으면 true, 세는 중이면 [_countEnd] 가 있다. + bool _counted = false; + DateTime? _countEnd; /// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각. bool _playing = false; @@ -89,6 +100,17 @@ class _MusicShakePlayScreenState extends State { _playing ? _anchorMedia + wall.difference(_anchorWall) : null; 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(() {}); _playing = e.playing; _anchorMedia = e.position; @@ -98,6 +120,17 @@ class _MusicShakePlayScreenState extends State { } 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()); if (now == null || _done) return; final judged = _judge.live(now).judged; @@ -120,6 +153,7 @@ class _MusicShakePlayScreenState extends State { Widget build(BuildContext context) { final now = _mediaAt(DateTime.now()) ?? _anchorMedia; final v = _judge.live(now); + final left = _countEnd?.difference(DateTime.now()); return Scaffold( backgroundColor: AppColors.cosmicVoid, appBar: AppBar( @@ -138,19 +172,21 @@ class _MusicShakePlayScreenState extends State { ), const SizedBox(height: 32), Text( - switch (v.last) { - ShakeHit.perfect => 'PERFECT', - ShakeHit.good => 'GOOD', - ShakeHit.miss => 'MISS', - null => ' ', - }, + left != null + ? '${(left.inMilliseconds / 1000).ceil().clamp(1, 99)}' + : switch (v.last) { + ShakeHit.perfect => 'PERFECT', + ShakeHit.good => 'GOOD', + ShakeHit.miss => 'MISS', + null => ' ', + }, key: const ValueKey('shake-last'), textAlign: TextAlign.center, style: TextStyle( - fontSize: 34, + fontSize: left != null ? 62 : 34, fontWeight: FontWeight.w700, - height: 1.3, - color: switch (v.last) { + height: left != null ? 1.4 : 1.3, + color: left != null ? AppColors.onCosmic : switch (v.last) { ShakeHit.perfect => AppColors.cosmicAccent, ShakeHit.good => const Color(0xFF67D3A9), _ => const Color(0xFF777777), @@ -201,9 +237,21 @@ class _MusicShakePlayScreenState extends State { videoId: widget.setup.videoId, onEvent: onEvent, 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 · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다. class ShakeTally extends StatelessWidget { const ShakeTally({ @@ -275,9 +323,11 @@ class YoutubeEmbed extends StatefulWidget { required this.videoId, required this.onEvent, this.start = Duration.zero, + this.control, }); final String videoId; + final ShakePlayerControl? control; /// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다. final Duration start; @@ -340,6 +390,7 @@ class _YoutubeEmbedState extends State { if (web.platform case final AndroidWebViewController android) { unawaited(android.setMediaPlaybackRequiresUserGesture(false)); } + widget.control?._run = (js) => unawaited(web.runJavaScript(js)); unawaited( web.loadHtmlString( YoutubeEmbed.html( diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 6c2c6ca..2709ddd 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -370,6 +370,7 @@ class QuestSession { this.position = Duration.zero, this.hits = const [], this.judgement, + this.firstBeat, }); /// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나. @@ -384,6 +385,16 @@ class QuestSession { /// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점. 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. final List hits; @@ -414,6 +425,7 @@ class QuestCard { this.publishState = '', this.authorName = '', this.session, + this.staleShake = false, }); final String id; @@ -423,6 +435,10 @@ class QuestCard { /// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null. final QuestSession? session; + /// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다. + /// 이때 [session] 은 null 이고 카드는 「시작하기」로 보인다 — 시작하면 안내를 한 번 띄운다. + final bool staleShake; + /// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다. /// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다. final String authorName; @@ -473,6 +489,7 @@ class QuestCard { /// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다. QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard( session: session, + staleShake: staleShake, id: id, title: title, categoryName: categoryName, @@ -500,7 +517,9 @@ class MusicShakeSetup { required this.title, required this.bpm, required this.segments, + this.firstBeat = Duration.zero, this.windows = const ShakeWindows(), + this.leadIn = const Duration(seconds: 3), }); /// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID. @@ -512,9 +531,19 @@ class MusicShakeSetup { /// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02). final double bpm; - /// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02). + /// `quests.totalSegments` — 판정 박 수. 곡 전체를 덮도록 운영자가 정한다. 🟡 회신 대기(Q-MS-02). final int segments; + + /// `quests.firstBeatMs` — 영상에서 첫 박 시각. 🟡 임시값 — 회신 대기(Q-MS-02 · Q-MS-03). 없으면 0. + final Duration firstBeat; 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 { diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index d0cf9b1..8d96723 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -850,12 +850,19 @@ class _QuestDetailScreenState extends State { return; } if (!mounted) return; + // 운영값(첫 박 · 판 길이)이 바뀐 뒤의 옛 일시 정지는 박 위치가 맞지 않는다 — 버리고 새 판으로 시작한다. + // 목록 · 상세가 이미 시작하기로 보이는 경우(staleShake)도 같은 안내를 한 번 한다. + if (detail.card.staleShake || (resume != null && !resume.fitsShake(setup))) { + resume = null; + _toast(context.l10n.questShakeRestart); + } final judge = ShakeJudge( bpm: setup.bpm, segments: setup.segments, + firstBeat: setup.firstBeat, windows: setup.windows, hits: resume?.hits ?? const [], - position: resume?.position ?? Duration.zero, + position: resume?.position ?? setup.startAt, ); QuestSession snapshot(QuestStage state) => QuestSession( state: state, @@ -863,6 +870,7 @@ class _QuestDetailScreenState extends State { total: setup.segments, position: judge.position, hits: judge.hits, + firstBeat: setup.firstBeat, ); setState(() => _stage = QuestStage.inProgress); // 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다. @@ -900,6 +908,7 @@ class _QuestDetailScreenState extends State { state: QuestStage.completed, progress: setup.segments, total: setup.segments, + firstBeat: setup.firstBeat, judgement: run.judgement(), ), ); diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart index ffdb199..c9b8a44 100644 --- a/app/lib/features/quest/quest_evidence.dart +++ b/app/lib/features/quest/quest_evidence.dart @@ -150,7 +150,7 @@ typedef ShakeLive = ({ /// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24). /// /// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라 -/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박 +/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박 /// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good] /// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간 /// 수) 서버가 끊는다. @@ -158,6 +158,7 @@ class ShakeJudge { ShakeJudge({ required this.bpm, required this.segments, + this.firstBeat = Duration.zero, this.windows = const ShakeWindows(), List hits = const [], this.position = Duration.zero, @@ -170,6 +171,9 @@ class ShakeJudge { final double bpm; final int segments; + + /// `quests.firstBeatMs` — 영상에서 첫 박이 오는 시각. 박 i 는 `firstBeat + i * 60/bpm` 이다. + final Duration firstBeat; final ShakeWindows windows; /// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다. @@ -184,13 +188,19 @@ class ShakeJudge { Duration get beat => Duration(microseconds: (60e6 / bpm).round()); /// 마지막 박의 판정 창이 닫히는 영상 시각. - Duration get end => Duration( - microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(), - ); + Duration get end => + firstBeat + + Duration( + microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(), + ); + + /// 영상 시각 [at] 이 첫 박에서 몇 박 떨어졌나. + double _beats(Duration at) => + (at - firstBeat).inMicroseconds / beat.inMicroseconds; /// 영상 시각 [at] 의 흔들기 한 번. void add(Duration at) { - final pos = at.inMicroseconds / beat.inMicroseconds; + final pos = _beats(at); final i = pos.round(); if (i < 0 || i >= segments) return; final d = (pos - i).abs(); @@ -203,7 +213,7 @@ class ShakeJudge { ShakeLive live(Duration now) { var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0; ShakeHit? last; - final pos = now.inMicroseconds / beat.inMicroseconds; + final pos = _beats(now); for (var i = 0; i < segments && i + windows.good <= pos; i++) { judged++; final d = _best[i]; diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index dbc29bc..5c671b6 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -158,13 +158,26 @@ class FirestoreQuestRepository implements QuestRepository { /// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진 /// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null. - static QuestSession? pausedSessionOf(QuestType type, Map progress) { + static QuestSession? pausedSessionOf( + QuestType type, + Map progress, + Map quest, + ) { 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 : s; } + /// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션인가 — [QuestSession.fitsShake] 와 같은 기준. + static bool staleShake(QuestType type, Map 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). static QuestSession? sessionOf(Map progress) { final raw = progress['session']; @@ -185,6 +198,7 @@ class FirestoreQuestRepository implements QuestRepository { 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 [], + firstBeat: m['firstBeatMs'] is num ? Duration(milliseconds: m.intOr('firstBeatMs')) : null, judgement: judgement is Map ? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()} : null, @@ -238,13 +252,17 @@ class FirestoreQuestRepository implements QuestRepository { : type == QuestType.musicShake ? quest.intOr('bpm') : 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); // 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값. final walked = steps && session != null ? walk ?? session.progress : null; return QuestCard( session: session, + staleShake: stale, id: id, title: questTitle(quest), categoryName: type.category, @@ -561,6 +579,7 @@ class FirestoreQuestRepository implements QuestRepository { 'total': session.total, 'positionMs': session.position.inMilliseconds, if (session.hits.isNotEmpty) 'hits': session.hits, + if (session.firstBeat != null) 'firstBeatMs': session.firstBeat!.inMilliseconds, 'judgement': ?session.judgement, }, }); @@ -792,12 +811,18 @@ class FirestoreQuestRepository implements QuestRepository { if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) { 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( videoId: videoId, title: link.strOr('title', questTitle(quest)), bpm: bpm.toDouble(), 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, 'trackId': trackId, 'totalSegments': segments, + if (run != null) 'firstBeatMs': run.firstBeat.inMilliseconds, 'judgement': judgement, 'occurredAt': DateTime.now().toUtc().toIso8601String(), ...await _fandomField(), diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 60d6718..db93f31 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -586,6 +586,7 @@ "questDetails": "Details", "questInProgress": "In progress", "questResume": "Resume", + "questShakeRestart": "The quest settings changed, so you will start from the beginning.", "questPaused": "Paused", "questTypeAttend": "Check-in", "questTypeSteps": "Walking", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index ba06ece..f4a7e84 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -586,6 +586,7 @@ "questDetails": "Detalles", "questInProgress": "En curso", "questResume": "Reanudar", + "questShakeRestart": "La configuración de la misión cambió, así que empiezas desde el principio.", "questPaused": "En pausa", "questTypeAttend": "Asistencia", "questTypeSteps": "Caminar", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 685375e..f0825f2 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -586,6 +586,7 @@ "questDetails": "詳細", "questInProgress": "進行中", "questResume": "再開する", + "questShakeRestart": "クエストの設定が変わったため、最初から始めます", "questPaused": "一時停止", "questTypeAttend": "出席", "questTypeSteps": "ウォーキング", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index bf852c8..16bcbf1 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1163,6 +1163,7 @@ "questDetails": "자세히", "questInProgress": "진행중", "questResume": "이어하기", + "questShakeRestart": "퀘스트 설정이 바뀌어 처음부터 시작합니다", "questPaused": "일시 정지", "questTypeAttend": "출석", "questTypeSteps": "걷기", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 39f1b9c..15d8718 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -3620,6 +3620,12 @@ abstract class AppLocalizations { /// **'이어하기'** String get questResume; + /// No description provided for @questShakeRestart. + /// + /// In ko, this message translates to: + /// **'퀘스트 설정이 바뀌어 처음부터 시작합니다'** + String get questShakeRestart; + /// No description provided for @questPaused. /// /// In ko, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 1e4a38c..f09c3db 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2036,6 +2036,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get questResume => 'Resume'; + @override + String get questShakeRestart => + 'The quest settings changed, so you will start from the beginning.'; + @override String get questPaused => 'Paused'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 613e516..40d2b68 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2048,6 +2048,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get questResume => 'Reanudar'; + @override + String get questShakeRestart => + 'La configuración de la misión cambió, así que empiezas desde el principio.'; + @override String get questPaused => 'En pausa'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index d3424b2..2a11f67 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -1976,6 +1976,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get questResume => '再開する'; + @override + String get questShakeRestart => 'クエストの設定が変わったため、最初から始めます'; + @override String get questPaused => '一時停止'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 3143334..73aa0fc 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -1977,6 +1977,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get questResume => '이어하기'; + @override + String get questShakeRestart => '퀘스트 설정이 바뀌어 처음부터 시작합니다'; + @override String get questPaused => '일시 정지'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 383f33e..6d869ef 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -1974,6 +1974,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get questResume => '继续'; + @override + String get questShakeRestart => '任务设置已更改,将从头开始'; + @override String get questPaused => '已暂停'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index a26c9fb..15071ef 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -586,6 +586,7 @@ "questDetails": "详情", "questInProgress": "进行中", "questResume": "继续", + "questShakeRestart": "任务设置已更改,将从头开始", "questPaused": "已暂停", "questTypeAttend": "签到", "questTypeSteps": "步行", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index e1ba17f..019a674 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -2158,6 +2158,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied; + @override + String get questShakeRestart => _remote['questShakeRestart'] ?? _base.questShakeRestart; + @override String get questStart => _remote['questStart'] ?? _base.questStart; diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index d151425..578c7c2 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -263,10 +263,10 @@ void main() { test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async { final db = await _seed(); 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({ - '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; expect( diff --git a/app/test/music_shake_play_test.dart b/app/test/music_shake_play_test.dart index 5c5eef8..25892c5 100644 --- a/app/test/music_shake_play_test.dart +++ b/app/test/music_shake_play_test.dart @@ -19,6 +19,36 @@ void main() { 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 { final shakes = StreamController(); addTearDown(shakes.close); @@ -35,6 +65,7 @@ void main() { MaterialPageRoute( builder: (_) => MusicShakePlayScreen( setup: setup, + countdown: Duration.zero, shakes: shakes.stream, player: (onEvent) { player = onEvent; @@ -141,6 +172,7 @@ void main() { MaterialPageRoute( builder: (_) => MusicShakePlayScreen( setup: setup, + countdown: Duration.zero, shakes: shakes.stream, judge: judge, player: (onEvent) { @@ -181,4 +213,48 @@ void main() { expect(resume, contains('player.seekTo(9.91,true);player.playVideo();')); expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo'))); }); + + testWidgets('재생을 시작하면 3·2·1 을 세는 동안 판정하지 않고, 0 이 되면 판정이 시작된다', (tester) async { + final shakes = StreamController(); + addTearDown(shakes.close); + late ValueChanged 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.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}); + }); } diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index 5375212..24ae2de 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -408,7 +408,7 @@ service cloud.firestore { }, SetOptions(merge: true)); await db.doc('quests/shake').set({ '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'}, }); final backend = _RecordingBackend(db); @@ -423,20 +423,22 @@ service cloud.firestore { expect(setup.videoId, 'WMweEpGlu_U'); expect(setup.title, 'BTS - Butter'); 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)); // 판을 끝내기 전에는 서버를 부르지 않는다. await expectLater(repo.verify(card), throwsA(isA())); expect(backend.calls, isEmpty); - repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows) - ..add(Duration.zero)); + repo.recordShake(ShakeJudge(bpm: 120, segments: 3, firstBeat: setup.firstBeat, windows: setup.windows) + ..add(setup.firstBeat)); await repo.verify(card); final call = backend.calls.single; expect(call.name, 'fnQuestVerifyMusicShake'); expect(call.payload['questId'], 'shake'); expect(call.payload['trackId'], 'WMweEpGlu_U'); expect(call.payload['totalSegments'], 3); + expect(call.payload['firstBeatMs'], 17000); 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 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'); // 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다. expect(card.stage, QuestStage.paused); @@ -669,7 +671,7 @@ service cloud.firestore { expect(QuestTab.inProgress.accepts(card), isTrue); 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}, }}); card = _card(await repo.load(), 'shake'); @@ -680,7 +682,7 @@ service cloud.firestore { test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async { final db = await shakeSeed(); 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}, }}); final backend = _RecordingBackend(db); @@ -733,11 +735,11 @@ service cloud.firestore { 'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100, }); 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), }}); 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), }}); final repo = FirestoreQuestRepository(_RecordingBackend(db)); @@ -745,5 +747,23 @@ service cloud.firestore { expect(card?.id, 'shake'); 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)); + }); }); }