FPI 사용자·국가 랭킹 서버 집계 · YouTube 스밍 · Music Shake 참여
This commit is contained in:
@@ -26,7 +26,7 @@ const FORBIDDEN = [
|
||||
/** 서버 산출 필드 — 문서 자체는 쓸 수 있어도 이 필드는 master 도 못 바꾼다. */
|
||||
const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
fandoms: ['fpiScore', 'fpiAxes', 'fpiRanks', 'fpiRanksPrev', 'sfpi', 'growthPop',
|
||||
'rankingEligible', 'eclipsePenaltyRevision'],
|
||||
'rankingEligible', 'eclipsePenaltyRevision', 'memberRanked'],
|
||||
users: ['userExp', 'userLevel', 'passportTier', 'totalContributedPop'],
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
* 티커 이벤트 보존 창.
|
||||
@@ -885,39 +885,101 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-ranking-country` — 국가별 지표 랭킹 사전 집계(설계서 §10).
|
||||
* `fn-ranking-country` — **사용자 지표 랭킹 + 국가별 지표 랭킹** 사전 집계 · 5 분 주기(설계서 §2.6 · §4.4).
|
||||
*
|
||||
* 랭킹 화면은 `rankings/{rankingId}` 문서 1건만 읽는다. 축마다 문서를 따로 두어
|
||||
* 축을 바꾸면 다른 문서를 읽게 한다 — 한 문서에 다섯 축을 담으면 화면이 어느 축을
|
||||
* 그리는지 다시 분기해야 하고, 그 분기가 「지표를 바꿔도 같은 Ranking」의 자리다.
|
||||
* 1 분 FPI 집계(`fnFpiAggregate`)는 행성 단위만 적는다. 멤버 단위 값은 멤버 전수를 훑어야
|
||||
* 해서 국가 집계와 같은 5 분 배치에서 한 번에 낸다(설계서 §2.6 「멤버 전수 스캔이라 5 분」).
|
||||
*
|
||||
* 국가는 멤버 문서에 비정규화된 `countryCode`(사용자 프로필 기준)이며, 없으면
|
||||
* `ZZ`(미지정) 버킷으로 모은다.
|
||||
* 1. **멤버별 축 값** — 행성 FPI 와 같은 원천(최근 `fpiWindowDays` 일 · verified `fpiEvents`)을
|
||||
* `uid` 별로 합산해 `members/{uid}.fpiAxes` 에 적는다. Eclipse 차감은 행성 총점만 깎으므로
|
||||
* 개인 값에 넣지 않는다.
|
||||
* 2. **멤버 축별 순위** `rankByAxis` (C-19 1·1·3, 동점 내부 uid 오름차순) · 직전 순위
|
||||
* `rankPrevByAxis` (= 직전 배치의 `rankByAxis`, 행성 `fpiRanksPrev` 와 같은 정의) ·
|
||||
* 「내 상위 %」 `topPercentByAxis` = ⌈순위 ÷ 멤버 수 × 100⌉ — 그 축 기여가 0 이면 적지 않는다
|
||||
* (「상위 100%」는 최하위라는 사실 주장이 된다).
|
||||
* 3. **국가 합** — 위 멤버 값을 사용자 프로필 `users/{uid}.countryCode` 로 묶는다(없으면 `ZZ`).
|
||||
* 멤버 문서에도 `countryCode` · 공개 닉네임(`nickname`, M-7)을 비정규화해 앱이 「내 국가」 행과
|
||||
* 사용자 랭킹 이름을 멤버 문서 한 건으로 그리게 한다.
|
||||
*
|
||||
* 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
|
||||
* 여기만 전수로 세면 국가 합이 행성 랭킹의 합보다 커져 두 화면이 어긋난다.
|
||||
* 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」(B-17,
|
||||
* `rankingEligible`). 모집단에서 빠진 행성은 멤버 랭킹 필드를 지워 지난 순위가 남지 않게 한다.
|
||||
* 바뀐 멤버 문서만 쓴다.
|
||||
*
|
||||
* ponytail: 대상 행성의 멤버 전수 스캔이다. 멤버가 늘면 실행 시간을 넘으므로
|
||||
* 9주차 부하 설계에서 국가 카운터 샤드로 옮긴다. 지금은 화면이 실데이터로 도는
|
||||
* 것이 우선이다.
|
||||
* ponytail: 대상 행성의 멤버 · 7 일 이벤트 전수 스캔이다. 멤버가 늘어 실행 시간을 넘으면
|
||||
* 행성별 샤드 배치(Cloud Tasks)로 쪼갠다.
|
||||
*/
|
||||
export const fnRankingCountryAggregate = onSchedule('every 5 minutes', async () => {
|
||||
export const fnRankingCountryAggregate = onSchedule(
|
||||
{ schedule: 'every 5 minutes', timeoutSeconds: 280 },
|
||||
async () => { await aggregateRankings(); },
|
||||
);
|
||||
|
||||
const MEMBER_RANK_FIELDS = ['fpiAxes', 'rankByAxis', 'rankPrevByAxis', 'topPercentByAxis'] as const;
|
||||
|
||||
export async function aggregateRankings(now = Date.now()): Promise<{ fandoms: number; members: number; countries: number }> {
|
||||
const db = getFirestore();
|
||||
const eligible = await db.collection('fandoms')
|
||||
.where('rankingEligible', '==', true).select().get();
|
||||
const members = (await Promise.all(
|
||||
eligible.docs.map((f) => f.ref.collection('members').get()),
|
||||
)).flatMap((snap) => snap.docs);
|
||||
const policy = await loadPolicy();
|
||||
const since = Timestamp.fromMillis(now - policy.fpiWindowDays * 24 * 60 * 60 * 1000);
|
||||
const [eligible, ranked] = await Promise.all([
|
||||
db.collection('fandoms').where('rankingEligible', '==', true).select().get(),
|
||||
db.collection('fandoms').where('memberRanked', '==', true).select('rankingEligible').get(),
|
||||
]);
|
||||
const writer = db.bulkWriter();
|
||||
|
||||
// 모집단에서 빠진 행성 — 지난 순위를 지운다. 남겨 두면 활동 없는 행성에 옛 순위 · 상위 % 가 뜬다.
|
||||
for (const fandom of ranked.docs) {
|
||||
if (fandom.get('rankingEligible') === true) continue;
|
||||
const members = await fandom.ref.collection('members').select(...MEMBER_RANK_FIELDS).get();
|
||||
for (const m of members.docs) {
|
||||
if (MEMBER_RANK_FIELDS.some((f) => m.get(f) !== undefined)) {
|
||||
void writer.update(m.ref, Object.fromEntries(MEMBER_RANK_FIELDS.map((f) => [f, FieldValue.delete()])));
|
||||
}
|
||||
}
|
||||
void writer.update(fandom.ref, { memberRanked: false });
|
||||
}
|
||||
|
||||
const totals = new Map<string, Record<FpiAxis, number>>();
|
||||
for (const member of members) {
|
||||
const code = (member.get('countryCode') as string | undefined) || 'ZZ';
|
||||
const axes = (member.get('fpiAxes') as Record<FpiAxis, number> | undefined) ?? {} as Record<FpiAxis, number>;
|
||||
const bucket = totals.get(code)
|
||||
?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
|
||||
for (const axis of FPI_AXES) bucket[axis] += axes[axis] ?? 0;
|
||||
totals.set(code, bucket);
|
||||
let memberTotal = 0;
|
||||
for (const fandom of eligible.docs) {
|
||||
const [members, events] = await Promise.all([
|
||||
fandom.ref.collection('members').get(),
|
||||
fandom.ref.collection('fpiEvents').where('verified', '==', true).where('occurredAt', '>=', since).get(),
|
||||
]);
|
||||
const values = new Map<string, Record<FpiAxis, number>>(
|
||||
members.docs.map((m) => [m.id, zeroAxes()]));
|
||||
for (const ev of events.docs) {
|
||||
const bucket = values.get(ev.get('uid') as string);
|
||||
const axis = ev.get('axis') as FpiAxis;
|
||||
const value = ev.get('value');
|
||||
// 차감 이벤트(uid · axis 없음)와 탈퇴한 사용자의 이벤트는 개인 값에 넣지 않는다.
|
||||
if (bucket && axis in bucket && typeof value === 'number' && Number.isFinite(value)) bucket[axis] += value;
|
||||
}
|
||||
const patches = memberRankPatches(values);
|
||||
const profiles = await profilesOf(members.docs.map((m) => m.id));
|
||||
for (const member of members.docs) {
|
||||
const patch = patches.get(member.id)!;
|
||||
const profile = profiles.get(member.id);
|
||||
const code = profile?.countryCode ?? 'ZZ';
|
||||
const next = {
|
||||
fpiAxes: patch.fpiAxes,
|
||||
rankByAxis: patch.rankByAxis,
|
||||
// 직전 순위 = 직전 배치의 순위. 첫 배치는 현재 순위(변동 0)로 둔다.
|
||||
rankPrevByAxis: (member.get('rankByAxis') as Record<string, number> | undefined) ?? patch.rankByAxis,
|
||||
topPercentByAxis: patch.topPercentByAxis,
|
||||
countryCode: code,
|
||||
// 사용자 랭킹의 이름 칸 — 공개 닉네임만(M-7). 프로필이 없으면 「팬」.
|
||||
nickname: profile?.nickname ?? PUBLIC_NAME_FALLBACK,
|
||||
};
|
||||
if (Object.entries(next).some(([k, v]) => stable(member.get(k)) !== stable(v))) {
|
||||
void writer.update(member.ref, next);
|
||||
}
|
||||
const total = totals.get(code) ?? zeroAxes();
|
||||
for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis];
|
||||
totals.set(code, total);
|
||||
}
|
||||
void writer.update(fandom.ref, { memberRanked: true });
|
||||
memberTotal += members.size;
|
||||
}
|
||||
await writer.close();
|
||||
|
||||
for (const axis of FPI_AXES) {
|
||||
const doc = db.doc(`rankings/country-${axis}`);
|
||||
@@ -938,10 +1000,63 @@ export const fnRankingCountryAggregate = onSchedule('every 5 minutes', async ()
|
||||
}));
|
||||
await doc.set({ axis, rows, updatedAt: FieldValue.serverTimestamp() });
|
||||
}
|
||||
logger.info('[ranking] 국가 집계 완료', {
|
||||
countries: totals.size, fandoms: eligible.size, members: members.length,
|
||||
});
|
||||
const result = { fandoms: eligible.size, members: memberTotal, countries: totals.size };
|
||||
logger.info('[ranking] 사용자 · 국가 집계 완료', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 키 순서와 무관한 비교용 직렬화 — Firestore 가 돌려주는 맵의 키 순서는 쓴 순서와 다를 수 있다. */
|
||||
function stable(value: unknown): string {
|
||||
return JSON.stringify(value, (_, v: unknown) => v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1))) : v) ?? 'undefined';
|
||||
}
|
||||
|
||||
function zeroAxes(): Record<FpiAxis, number> {
|
||||
return Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
|
||||
}
|
||||
|
||||
/** 사용자 프로필의 국가 코드 · 공개 닉네임. 한 번에 읽는 수는 300건씩 자른다. */
|
||||
async function profilesOf(uids: string[]): Promise<Map<string, { countryCode: string | undefined; nickname: string }>> {
|
||||
const db = getFirestore();
|
||||
const out = new Map<string, { countryCode: string | undefined; nickname: string }>();
|
||||
for (let i = 0; i < uids.length; i += 300) {
|
||||
const refs = uids.slice(i, i + 300).map((uid) => db.doc(`users/${uid}`));
|
||||
if (refs.length === 0) continue;
|
||||
for (const snap of await db.getAll(...refs, { fieldMask: ['countryCode', 'nickname'] })) {
|
||||
if (!snap.exists) continue;
|
||||
const code = snap.get('countryCode');
|
||||
out.set(snap.id, {
|
||||
countryCode: typeof code === 'string' && /^[A-Z]{2}$/.test(code) ? code : undefined,
|
||||
nickname: publicNameOf(snap),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 행성 멤버의 축별 순위 · 「내 상위 %」(순수 계산). 순위는 C-19 공동 순위 — 실값 내림차순,
|
||||
* 동점은 같은 순위, 동점 내부는 uid 오름차순. 상위 % 는 ⌈순위 ÷ 멤버 수 × 100⌉(최소 1),
|
||||
* 그 축 값이 0 이면 비운다.
|
||||
*/
|
||||
export function memberRankPatches(values: ReadonlyMap<string, Record<FpiAxis, number>>): Map<string, {
|
||||
fpiAxes: Record<FpiAxis, number>;
|
||||
rankByAxis: Partial<Record<FpiAxis, number>>;
|
||||
topPercentByAxis: Partial<Record<FpiAxis, number>>;
|
||||
}> {
|
||||
const out = new Map([...values].map(([uid, fpiAxes]) => [uid, {
|
||||
fpiAxes, rankByAxis: {} as Partial<Record<FpiAxis, number>>, topPercentByAxis: {} as Partial<Record<FpiAxis, number>>,
|
||||
}]));
|
||||
const n = values.size;
|
||||
for (const axis of FPI_AXES) {
|
||||
for (const { item: [uid, axes], rank } of rankWithTies([...values], ([, a]) => a[axis], ([uid]) => uid)) {
|
||||
const row = out.get(uid)!;
|
||||
row.rankByAxis[axis] = rank;
|
||||
if (axes[axis] > 0) row.topPercentByAxis[axis] = Math.max(1, Math.ceil(rank / n * 100));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-campaign-fund` — 캠페인 후원.
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
|
||||
} from './ledger/pop.js';
|
||||
export {
|
||||
fnQuestAttend, fnQuestVerifySteps, fnAdReward,
|
||||
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate,
|
||||
} from './quest/quest.js';
|
||||
export {
|
||||
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
|
||||
|
||||
@@ -169,6 +169,44 @@ export const fnQuestAttend = onCall(async (req) => {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(원장 C-04 · B-25).
|
||||
*
|
||||
* POP 원장을 쓰지 않는다(검증 기준 「POP 지급 원장 0건」). 참여 기록(`questProgress`)과 FPI 원천
|
||||
* 이벤트만 같은 트랜잭션에 남긴다 — 보상이 없는 유효 활동도 FPI 에 들어간다(C-34).
|
||||
*
|
||||
* - 축은 **COMMUNITY**. C-35 매핑에 Streaming 이 없어, 같은 외부 행동(스트리밍 · 투표)의 TIME ATTACK
|
||||
* 참여 1건 = COMMUNITY 1회(기능명세 §TIME ATTACK)를 따른다. 확인 필요 항목으로 보고한다.
|
||||
* - 외부 플랫폼의 실제 재생 완료는 검증할 수 없으므로 **「외부 행동 완료」로 판정하지 않는다**(C-92).
|
||||
* 그래서 「검증 활동」 표식(`verifiedCompletions`, 생성 자격 집계)은 남기지 않는다.
|
||||
* - 한도는 다른 운영 Quest 와 같이 `quests/{id}.dailyLimit`(없으면 하루 1회).
|
||||
*/
|
||||
export const fnQuestStreamParticipate = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const questId = str(req.data, 'questId');
|
||||
return participateStreaming(uid, questId);
|
||||
});
|
||||
|
||||
export async function participateStreaming(uid: string, questId: string, now = new Date()) {
|
||||
await questOf(uid, questId, ['streaming', 'stream', 'youtube']);
|
||||
const db = getFirestore();
|
||||
const key = dayKey(now);
|
||||
const limit = await dailyLimitOf(questId);
|
||||
const ref = db.doc(`users/${uid}/questProgress/${questId}`);
|
||||
const count = await db.runTransaction(async (tx) => {
|
||||
const [snap, user] = await Promise.all([tx.get(ref), tx.get(db.doc(`users/${uid}`))]);
|
||||
const done = snap.get('dayKey') === key ? ((snap.get('count') as number | undefined) ?? 0) : 0;
|
||||
if (done >= limit) fail(ERR.popDuplicate, '오늘은 이미 참여했습니다.');
|
||||
progressWriter(ref, questId, key, done + 1, 0)(tx);
|
||||
writeFpiEvent(tx, {
|
||||
uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'streaming',
|
||||
sourceId: `stream:${questId}:${key}:${done + 1}`, occurredAt: now,
|
||||
}, user);
|
||||
return done + 1;
|
||||
});
|
||||
return { rewarded: 0, questId, remaining: limit - count };
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-quest-verify-steps` — 만보기.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { aggregateRankings, memberRankPatches } from '../src/fandom/fandom.js';
|
||||
import { participateStreaming } from '../src/quest/quest.js';
|
||||
import { FPI_AXES, type FpiAxis } from '../src/core/config.js';
|
||||
|
||||
/**
|
||||
* 사용자 · 국가 지표 랭킹 5분 집계(`fnRankingCountryAggregate`) · 스트리밍 무보상 참여(C-04).
|
||||
*
|
||||
* 화면이 읽는 필드 — `members/{uid}.fpiAxes · rankByAxis · rankPrevByAxis · topPercentByAxis ·
|
||||
* countryCode · nickname` 과 `rankings/country-{축}` — 가 같은 원천(7일 fpiEvents)에서 나오는지 본다.
|
||||
*/
|
||||
|
||||
const db = () => getFirestore();
|
||||
const A = 'rank-test-a';
|
||||
const B = 'rank-test-b';
|
||||
const IDLE = 'rank-test-idle';
|
||||
const USERS = ['rank-u1', 'rank-u2', 'rank-u3'] as const;
|
||||
|
||||
const axes = (v: Partial<Record<FpiAxis, number>>) =>
|
||||
Object.fromEntries(FPI_AXES.map((a) => [a, v[a] ?? 0])) as Record<FpiAxis, number>;
|
||||
|
||||
async function wipe() {
|
||||
for (const id of [A, B, IDLE]) await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||
for (const uid of USERS) await db().recursiveDelete(db().doc(`users/${uid}`));
|
||||
for (const axis of FPI_AXES) await db().doc(`rankings/country-${axis}`).delete();
|
||||
await db().doc('quests/rank-stream').delete();
|
||||
}
|
||||
|
||||
async function event(fandom: string, uid: string | null, axis: FpiAxis | null, value: number, daysAgo = 0) {
|
||||
await db().collection(`fandoms/${fandom}/fpiEvents`).add({
|
||||
...(uid ? { uid } : {}), ...(axis ? { axis } : { kind: 'score_adjustment' }),
|
||||
value, verified: true, occurredAt: Timestamp.fromMillis(Date.now() - daysAgo * 86_400_000),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
beforeEach(wipe);
|
||||
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||
|
||||
describe('memberRankPatches (순수)', () => {
|
||||
it('1·1·3 공동 순위 · 동점 내부 uid 오름차순 · 상위 % 는 기여 0 이면 비운다', () => {
|
||||
const out = memberRankPatches(new Map([
|
||||
['c', axes({ bpm: 5 })], ['a', axes({ bpm: 5 })], ['b', axes({ bpm: 1 })], ['d', axes({})],
|
||||
]));
|
||||
expect(out.get('a')!.rankByAxis.bpm).toBe(1);
|
||||
expect(out.get('c')!.rankByAxis.bpm).toBe(1);
|
||||
expect(out.get('b')!.rankByAxis.bpm).toBe(3);
|
||||
expect(out.get('d')!.rankByAxis.bpm).toBe(4);
|
||||
// ⌈순위 ÷ 4 × 100⌉
|
||||
expect(out.get('a')!.topPercentByAxis.bpm).toBe(25);
|
||||
expect(out.get('b')!.topPercentByAxis.bpm).toBe(75);
|
||||
expect(out.get('d')!.topPercentByAxis.bpm).toBeUndefined();
|
||||
expect(out.get('a')!.topPercentByAxis.health).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateRankings — 사용자 · 국가 랭킹 (B-17 · C-19)', () => {
|
||||
it('멤버 축 값 · 순위 · 직전 순위 · 상위 % · 국가 합을 7일 이벤트에서 낸다', async () => {
|
||||
await db().doc(`users/${USERS[0]}`).set({ countryCode: 'KR', nickname: '하나' });
|
||||
await db().doc(`users/${USERS[1]}`).set({ countryCode: 'JP', nickname: 'a@b.c' });
|
||||
await db().doc(`fandoms/${A}`).set({ stage: 1, rankingEligible: true });
|
||||
for (const uid of USERS) await db().doc(`fandoms/${A}/members/${uid}`).set({ uid });
|
||||
await event(A, USERS[0], 'bpm', 3);
|
||||
await event(A, USERS[1], 'bpm', 5);
|
||||
await event(A, USERS[1], 'community', 2);
|
||||
await event(A, USERS[0], 'bpm', 100, 8); // 창 밖
|
||||
await event(A, null, null, -50); // Eclipse 차감 — 개인 값 아님
|
||||
await event(A, 'left-user', 'bpm', 9); // 탈퇴자 — 멤버 아님
|
||||
|
||||
await aggregateRankings();
|
||||
const m = async (uid: string) => (await db().doc(`fandoms/${A}/members/${uid}`).get()).data()!;
|
||||
const u1 = await m(USERS[0]);
|
||||
expect(u1.fpiAxes.bpm).toBe(3);
|
||||
expect(u1.rankByAxis.bpm).toBe(2);
|
||||
expect(u1.rankPrevByAxis.bpm).toBe(2); // 첫 배치는 변동 0
|
||||
expect(u1.topPercentByAxis).toEqual({ bpm: 67 });
|
||||
expect(u1.countryCode).toBe('KR');
|
||||
expect(u1.nickname).toBe('하나');
|
||||
const u2 = await m(USERS[1]);
|
||||
expect(u2.rankByAxis.bpm).toBe(1);
|
||||
expect(u2.topPercentByAxis).toEqual({ bpm: 34, community: 34 });
|
||||
expect(u2.nickname).toBe('팬'); // 이메일 형태 닉네임은 쓰지 않는다(M-7)
|
||||
expect((await m(USERS[2])).countryCode).toBe('ZZ'); // 프로필 없음
|
||||
|
||||
const bpm = (await db().doc('rankings/country-bpm').get()).get('rows');
|
||||
expect(bpm).toEqual([
|
||||
{ code: 'JP', value: 5, rank: 1, change: 0 },
|
||||
{ code: 'KR', value: 3, rank: 2, change: 0 },
|
||||
{ code: 'ZZ', value: 0, rank: 3, change: 0 },
|
||||
]);
|
||||
|
||||
// 순위가 뒤집히면 직전 순위가 남아 ▲▼ 근거가 된다.
|
||||
await event(A, USERS[0], 'bpm', 10);
|
||||
await aggregateRankings();
|
||||
const after = await m(USERS[0]);
|
||||
expect(after.rankByAxis.bpm).toBe(1);
|
||||
expect(after.rankPrevByAxis.bpm).toBe(2);
|
||||
const rows = (await db().doc('rankings/country-bpm').get()).get('rows');
|
||||
expect(rows[0]).toEqual({ code: 'KR', value: 13, rank: 1, change: 1 });
|
||||
});
|
||||
|
||||
it('모집단 밖(최근 7일 활동 없음 · 성운)은 집계하지 않고 지난 순위를 지운다', async () => {
|
||||
await db().doc(`fandoms/${IDLE}`).set({ stage: 2, rankingEligible: true });
|
||||
await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).set({ uid: USERS[0] });
|
||||
await event(IDLE, USERS[0], 'health', 4);
|
||||
await db().doc(`fandoms/${B}`).set({ stage: 0, rankingEligible: false });
|
||||
await db().doc(`fandoms/${B}/members/${USERS[1]}`).set({ uid: USERS[1] });
|
||||
await event(B, USERS[1], 'health', 7);
|
||||
|
||||
await aggregateRankings();
|
||||
expect((await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).get()).get('rankByAxis.health')).toBe(1);
|
||||
expect((await db().doc(`fandoms/${B}/members/${USERS[1]}`).get()).get('fpiAxes')).toBeUndefined();
|
||||
|
||||
// 1분 집계가 모집단에서 내렸다(B-17).
|
||||
await db().doc(`fandoms/${IDLE}`).update({ rankingEligible: false });
|
||||
await aggregateRankings();
|
||||
const idle = (await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).get()).data()!;
|
||||
expect(idle.fpiAxes).toBeUndefined();
|
||||
expect(idle.rankByAxis).toBeUndefined();
|
||||
expect(idle.topPercentByAxis).toBeUndefined();
|
||||
expect((await db().doc(`fandoms/${IDLE}`).get()).get('memberRanked')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('participateStreaming — 무보상 기본 참여 (C-04 · B-25)', () => {
|
||||
it('POP 원장 없이 참여 기록과 FPI 원천만 남기고 하루 한도를 지킨다', async () => {
|
||||
const uid = USERS[2];
|
||||
await db().doc(`users/${uid}`).set({ primaryFandomId: A });
|
||||
await db().doc(`fandoms/${A}`).set({ stage: 1 });
|
||||
await db().doc('quests/rank-stream').set({ type: 'streaming', dailyLimit: 1, rewardPop: 10 });
|
||||
|
||||
const result = await participateStreaming(uid, 'rank-stream');
|
||||
expect(result).toMatchObject({ rewarded: 0, remaining: 0 });
|
||||
expect((await db().collection(`users/${uid}/popTransactions`).get()).size).toBe(0);
|
||||
expect((await db().doc(`users/${uid}/questProgress/rank-stream`).get()).get('rewardedPop')).toBe(0);
|
||||
const events = await db().collection(`fandoms/${A}/fpiEvents`).where('uid', '==', uid).get();
|
||||
expect(events.docs.map((e) => [e.get('axis'), e.get('activity'), e.get('value')]))
|
||||
.toEqual([['community', 'streaming', 1]]);
|
||||
// 외부 재생 완료는 검증할 수 없어 「검증 활동」으로 세지 않는다(C-92).
|
||||
expect((await db().collection(`users/${uid}/verifiedCompletions`).get()).size).toBe(0);
|
||||
|
||||
await expect(participateStreaming(uid, 'rank-stream')).rejects.toThrow();
|
||||
await db().doc('quests/rank-stream').update({ type: 'attend' });
|
||||
await expect(participateStreaming(uid, 'rank-stream', new Date(Date.now() + 86_400_000))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ class PlanetNode {
|
||||
this.stage,
|
||||
this.colorKey,
|
||||
bool? isCenter,
|
||||
}) : isCenter = isCenter ?? isMine;
|
||||
bool? ranked,
|
||||
}) : isCenter = isCenter ?? isMine,
|
||||
ranked = ranked ?? stage != 0;
|
||||
|
||||
/// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때
|
||||
/// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다.
|
||||
@@ -38,8 +40,16 @@ class PlanetNode {
|
||||
///
|
||||
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
|
||||
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
|
||||
/// 최근 7 일 유효 활동이 없는 정식 행성도 같다([ranked]).
|
||||
final int rank;
|
||||
|
||||
/// FPI 랭킹 모집단인가(B-17 — 정식 행성 중 최근 7 일 유효 활동 존재). false 면 [rank] 는
|
||||
/// 궤도 자리일 뿐이라 화면은 순위 대신 「-」를 쓴다.
|
||||
final bool ranked;
|
||||
|
||||
/// 화면에 쓰는 순위 문구 — 모집단 밖이면 「-」.
|
||||
String get rankLabel => ranked ? '$rank' : '-';
|
||||
|
||||
/// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다.
|
||||
final int fpi;
|
||||
|
||||
@@ -104,7 +114,7 @@ class GalaxySnapshot {
|
||||
GalaxySnapshot get rankingView {
|
||||
PlanetNode? top;
|
||||
for (final p in planets) {
|
||||
if (p.stage != 0 && (top == null || p.rank < top.rank)) top = p;
|
||||
if (p.ranked && (top == null || p.rank < top.rank)) top = p;
|
||||
}
|
||||
return GalaxySnapshot(
|
||||
planets: [
|
||||
@@ -118,6 +128,8 @@ class GalaxySnapshot {
|
||||
online: p.online,
|
||||
isMine: p.isMine,
|
||||
stage: p.stage,
|
||||
colorKey: p.colorKey,
|
||||
ranked: p.ranked,
|
||||
isCenter: identical(p, top),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -57,11 +57,13 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
|
||||
// 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는
|
||||
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
|
||||
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
|
||||
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
|
||||
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
|
||||
d.data()['stage'] == 0;
|
||||
final formal = fandoms.where((d) => !isNebula(d)).toList();
|
||||
// 랭킹 모집단 밖(B-08 · B-17)은 순위를 매기기 **전에** 뺀다 — 끼면 정식 행성의 순위
|
||||
// 번호가 밀린다. Stage0 성운과 서버가 `rankingEligible: false`(최근 7 일 유효 활동 없음)로
|
||||
// 내린 행성이다. 이들은 맨 바깥 궤도 자리(모집단 수 + 1)에 두고 순위는 「-」로 그린다.
|
||||
// 필드가 아직 없는 문서(집계 배포 전)는 Stage 만으로 판정한다.
|
||||
bool unranked(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
|
||||
d.data()['stage'] == 0 || d.data()['rankingEligible'] == false;
|
||||
final formal = fandoms.where((d) => !unranked(d)).toList();
|
||||
final planets = <PlanetNode>[
|
||||
for (final (doc, rank) in rankWithTies(
|
||||
formal,
|
||||
@@ -69,8 +71,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
(d) => d.id,
|
||||
))
|
||||
_node(doc.id, doc.data(), rank, myFandomId),
|
||||
for (final doc in fandoms.where(isNebula))
|
||||
_node(doc.id, doc.data(), formal.length + 1, myFandomId),
|
||||
for (final doc in fandoms.where(unranked))
|
||||
_node(doc.id, doc.data(), formal.length + 1, myFandomId, ranked: false),
|
||||
];
|
||||
final byId = {for (final p in planets) p.id: p};
|
||||
final membershipIds = [for (final d in (await membershipsF).docs) d.id];
|
||||
@@ -81,8 +83,15 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
]);
|
||||
for (final doc in missing) {
|
||||
final data = doc.data();
|
||||
if (data != null)
|
||||
byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId);
|
||||
if (data != null) {
|
||||
byId[doc.id] = _node(
|
||||
doc.id,
|
||||
data,
|
||||
formal.length + 1,
|
||||
myFandomId,
|
||||
ranked: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return GalaxySnapshot(
|
||||
@@ -100,8 +109,10 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
String id,
|
||||
Map<String, dynamic> data,
|
||||
int rank,
|
||||
String myFandomId,
|
||||
) => PlanetNode(
|
||||
String myFandomId, {
|
||||
bool ranked = true,
|
||||
}) => PlanetNode(
|
||||
ranked: ranked,
|
||||
id: id,
|
||||
stage: switch (data['stage']) {
|
||||
int n when n >= 0 && n <= 6 => n,
|
||||
|
||||
@@ -909,11 +909,8 @@ class _HudCard extends StatelessWidget {
|
||||
style: t.titleSmall,
|
||||
),
|
||||
),
|
||||
// Stage0 성운은 랭킹 모집단이 아니다(B-17).
|
||||
_Metric(
|
||||
label: 'RANK',
|
||||
value: planet.stage == 0 ? '-' : '${planet.rank}',
|
||||
),
|
||||
// Stage0 성운 · 최근 7 일 활동 없는 행성은 랭킹 모집단이 아니다(B-17).
|
||||
_Metric(label: 'RANK', value: planet.rankLabel),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
|
||||
@@ -286,7 +286,7 @@ class _Hit extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${planet.rank}',
|
||||
planet.rankLabel,
|
||||
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s4),
|
||||
|
||||
@@ -234,7 +234,7 @@ class _PlanetHit extends StatelessWidget {
|
||||
height: side,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.galaxyPlanetRank(planet.name, '${planet.rank}'),
|
||||
label: context.l10n.galaxyPlanetRank(planet.name, planet.rankLabel),
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
||||
@@ -446,7 +446,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
|
||||
static List<String> hudLines(PlanetNode p) => [
|
||||
p.name.toUpperCase(),
|
||||
'RANK #${p.rank}',
|
||||
'RANK #${p.rankLabel}',
|
||||
'FPI: ${p.fpi}',
|
||||
'LIVE: ${formatPop(p.online)}',
|
||||
];
|
||||
@@ -515,7 +515,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
|
||||
/// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
|
||||
static List<String> compactLines(PlanetNode p) => [
|
||||
'#${p.rank} · FPI ${formatPop(p.fpi)}',
|
||||
'#${p.rankLabel} · FPI ${formatPop(p.fpi)}',
|
||||
'LIVE ${formatPop(p.online)}',
|
||||
];
|
||||
|
||||
|
||||
@@ -134,9 +134,9 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
final axes = _map(fandom, 'fpiAxes');
|
||||
final ranks = _map(fandom, 'fpiRanks');
|
||||
final myTop = _map(member, 'topPercentByAxis');
|
||||
// MOCK: `topPercentByAxis` 는 서버 산출값인데 이를 쓰는 집계 함수가 아직
|
||||
// 없습니다. 필드는 실연동으로 읽고, **값이 없으면 null** 로 둡니다 —
|
||||
// 100%(최하위)로 채우면 없는 값이 사실처럼 보입니다. 대조표 3-5 참조.
|
||||
// `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다.
|
||||
// **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로
|
||||
// 채우면 없는 값이 사실처럼 보인다.
|
||||
int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num
|
||||
? myTop.intOr(_axisKey(metric))
|
||||
: null;
|
||||
@@ -189,9 +189,7 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
posts: posts,
|
||||
// 한 축이라도 서버 값이 없으면 배지로 알린다. 화면이 이 문구를 그대로
|
||||
// 띄운다 — 「집계 준비 중」 카드만으로는 이유가 보이지 않는다(대조표 3-5).
|
||||
mockNote: FpiMetric.values.any((m) => myTopOf(m) == null)
|
||||
? '「내 기여 상위 %」 집계 함수가 아직 없습니다'
|
||||
: null,
|
||||
mockNote: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,18 +286,7 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
(d) => _raw(d.data(), axis),
|
||||
(d) => d.id,
|
||||
))
|
||||
FpiRankRow(
|
||||
id: doc.id,
|
||||
rank: rank,
|
||||
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
|
||||
name: publicName(doc.data()['nickname']),
|
||||
value: _map(doc.data(), 'fpiAxes').intOr(axis),
|
||||
// MOCK: 멤버의 직전 축별 순위(`rankPrevByAxis`)를 쓰는 서버 집계가
|
||||
// 아직 없습니다. 필드가 없으면 현재 순위를 그대로 넣어 동향이 0 이 되고,
|
||||
// 화면은 ▲▼ 를 그리지 않습니다. 대조표 3-5 참조.
|
||||
change: _map(doc.data(), 'rankPrevByAxis').intOr(axis, rank) - rank,
|
||||
isMine: doc.id == uid,
|
||||
),
|
||||
_userRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid),
|
||||
];
|
||||
|
||||
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
|
||||
@@ -317,22 +304,45 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
// Figma `546:14668` 「나의 랭킹 영역」은 내가 몇 등이든 떠야 한다.
|
||||
final me = (await members.doc(uid).get()).data();
|
||||
if (me == null) return FpiRanking(rows: rows, mockNote: note);
|
||||
final myValue = _map(me, 'fpiAxes').intOr(axis);
|
||||
// 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19). 반올림한 값으로 세면 동률이 생긴다.
|
||||
final above = await members
|
||||
// 서버 순위가 없을 때만 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19)로 센다.
|
||||
final serverRank = _map(me, 'rankByAxis')[axis];
|
||||
final fallback = serverRank is num
|
||||
? 0
|
||||
: ((await members
|
||||
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
|
||||
.count()
|
||||
.get();
|
||||
.get())
|
||||
.count ??
|
||||
rows.length) +
|
||||
1;
|
||||
return FpiRanking(
|
||||
rows: rows,
|
||||
mockNote: note,
|
||||
mine: FpiRankRow(
|
||||
id: uid,
|
||||
rank: (above.count ?? rows.length) + 1,
|
||||
name: publicName(me['nickname'], appL10n.commonMe),
|
||||
value: myValue,
|
||||
isMine: true,
|
||||
),
|
||||
mine: _userRow(me, id: uid, axis: axis, fallbackRank: fallback, uid: uid,
|
||||
name: appL10n.commonMe),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사용자 랭킹 한 줄. 순위 · 직전 순위는 서버 5 분 집계(`rankByAxis` · `rankPrevByAxis`)를
|
||||
/// 읽는다 — 상위 % 와 같은 원천이라 두 값이 갈리지 않는다. 서버 값이 없을 때만 [fallbackRank].
|
||||
static FpiRankRow _userRow(
|
||||
Map<String, dynamic> data, {
|
||||
required String id,
|
||||
required String axis,
|
||||
required int fallbackRank,
|
||||
required String uid,
|
||||
String? name,
|
||||
}) {
|
||||
final rank = _map(data, 'rankByAxis').intOr(axis, fallbackRank);
|
||||
return FpiRankRow(
|
||||
id: id,
|
||||
rank: rank,
|
||||
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
|
||||
name: publicName(data['nickname'], name),
|
||||
value: _map(data, 'fpiAxes').intOr(axis),
|
||||
// 순위 동향 = 직전 순위 − 현재 순위. 직전 값이 없으면 0(▲▼ 없음).
|
||||
change: _map(data, 'rankPrevByAxis').intOr(axis, rank) - rank,
|
||||
isMine: id == uid,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ enum QuestType {
|
||||
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
|
||||
timeAttack,
|
||||
|
||||
/// 스밍·YouTube. 별첨 B 「1차 별도 완청 보상형 Quest 없음」 + Q-QC-04 회신 대기.
|
||||
/// 스밍·YouTube — 무보상 기본 참여(원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과
|
||||
/// FPI 원천만 남는다(`fn-quest-stream-participate`).
|
||||
streaming,
|
||||
|
||||
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
|
||||
@@ -109,6 +110,8 @@ enum QuestType {
|
||||
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
|
||||
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
|
||||
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
||||
if (this == QuestType.streaming) return 0;
|
||||
final raw = this == QuestType.attend
|
||||
? policy['attendPop']
|
||||
: quest['rewardPop'];
|
||||
@@ -117,19 +120,13 @@ enum QuestType {
|
||||
|
||||
/// 앱이 서버 검증까지 태울 수 있는 유형인가.
|
||||
///
|
||||
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
|
||||
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
|
||||
/// [streaming] 은 무보상 참여(C-04), [musicShake] 는 BPM 구간 판정(B-24 · `fn-quest-verify-music-shake`)으로
|
||||
/// 연다(Q-QC-04 9/16 회신). [unknown] 은 판정 함수를 모른다.
|
||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||
bool get verifiable => switch (this) {
|
||||
QuestType.attend ||
|
||||
QuestType.steps ||
|
||||
QuestType.place ||
|
||||
QuestType.timeAttack ||
|
||||
QuestType.vote ||
|
||||
QuestType.sns ||
|
||||
QuestType.community => true,
|
||||
QuestType.musicShake || QuestType.streaming || QuestType.unknown => false,
|
||||
};
|
||||
bool get verifiable => this != QuestType.unknown;
|
||||
|
||||
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
|
||||
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
|
||||
|
||||
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||
String? get blockedReason =>
|
||||
@@ -645,14 +642,15 @@ class SampleQuestRepository implements QuestRepository {
|
||||
// 잠근 상태를 표본에서도 보여 준다.
|
||||
const QuestCard(
|
||||
id: 'q6',
|
||||
title: '신곡 3분 완청',
|
||||
title: '신곡 스트리밍 참여',
|
||||
categoryName: '스트리밍',
|
||||
type: QuestType.streaming,
|
||||
author: QuestAuthor.admin,
|
||||
stage: QuestStage.notStarted,
|
||||
goal: 1,
|
||||
progress: 0,
|
||||
rewardPop: 10,
|
||||
// 무보상 기본 참여(C-04).
|
||||
rewardPop: 0,
|
||||
participants: 3420,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
@@ -86,8 +88,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
});
|
||||
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
|
||||
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
|
||||
if (_stage == QuestStage.inProgress &&
|
||||
detail.card.type == QuestType.steps) {
|
||||
if (_stage == QuestStage.inProgress && detail.card.type.gathers) {
|
||||
_watch(detail.card);
|
||||
}
|
||||
}
|
||||
@@ -593,7 +594,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
case QuestStage.notStarted:
|
||||
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
|
||||
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
|
||||
if (detail.card.goal > 1 && detail.card.type == QuestType.steps) {
|
||||
if (detail.card.goal > 1 && detail.card.type.gathers) {
|
||||
await _start(detail);
|
||||
} else {
|
||||
await _claim(detail);
|
||||
@@ -634,6 +635,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
.listen(
|
||||
(value) {
|
||||
if (!mounted) return;
|
||||
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
|
||||
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_live = value;
|
||||
if (card.goal > 0 && value >= card.goal) {
|
||||
@@ -712,6 +715,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
_busy = false;
|
||||
_stage = QuestStage.rewarded;
|
||||
});
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
|
||||
if (detail.card.type == QuestType.streaming) {
|
||||
_toast(context.l10n.questStreamJoined);
|
||||
final url = Uri.tryParse(detail.link?.url ?? '');
|
||||
if (url != null && url.hasScheme) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
await _load();
|
||||
return;
|
||||
}
|
||||
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
|
||||
if (reward.duplicate) {
|
||||
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:pedometer/pedometer.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -42,6 +45,72 @@ typedef StepCountProbe = Stream<int> Function();
|
||||
/// 현재 좌표 한 점을 내는 함수.
|
||||
typedef PlaceProbe = Future<PlaceFix> Function();
|
||||
|
||||
/// 흔들기 한 번마다 그 시각을 내는 스트림.
|
||||
typedef ShakeProbe = Stream<DateTime> Function();
|
||||
|
||||
/// 흔들기 감지 — 가속도 크기가 중력(9.8)을 크게 넘는 순간. 이클립스 방어 화면과 같은 문턱 ·
|
||||
/// 간격이다(같은 동작을 같은 기준으로 센다).
|
||||
Stream<DateTime> deviceShakes() async* {
|
||||
var last = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
await for (final e in accelerometerEventStream()) {
|
||||
final magnitude = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
|
||||
final now = DateTime.now();
|
||||
if (!magnitude.isFinite || magnitude < 18 ||
|
||||
now.difference(last) < const Duration(milliseconds: 220)) {
|
||||
continue;
|
||||
}
|
||||
last = now;
|
||||
yield now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||
///
|
||||
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
|
||||
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
|
||||
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
|
||||
class ShakeJudge {
|
||||
ShakeJudge({required this.bpm, required this.segments, required this.start})
|
||||
: assert(bpm > 0 && segments > 0);
|
||||
|
||||
final double bpm;
|
||||
final int segments;
|
||||
final DateTime start;
|
||||
|
||||
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
|
||||
static const perfectRatio = 0.2;
|
||||
|
||||
final _shakes = <DateTime>[];
|
||||
|
||||
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
||||
|
||||
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
|
||||
DateTime get end => start.add(beat * segments - beat ~/ 2);
|
||||
|
||||
void add(DateTime at) => _shakes.add(at);
|
||||
|
||||
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
|
||||
Map<String, int> judgement() {
|
||||
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
|
||||
final beatUs = beat.inMicroseconds;
|
||||
for (var i = 0; i < segments; i++) {
|
||||
final at = start.microsecondsSinceEpoch + beatUs * i;
|
||||
final best = _shakes
|
||||
.map((s) => (s.microsecondsSinceEpoch - at).abs())
|
||||
.where((d) => d < beatUs / 2)
|
||||
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
|
||||
if (best == null) {
|
||||
miss++;
|
||||
combo = 0;
|
||||
continue;
|
||||
}
|
||||
best <= beatUs * perfectRatio ? perfect++ : good++;
|
||||
maxCombo = math.max(maxCombo, ++combo);
|
||||
}
|
||||
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
|
||||
}
|
||||
}
|
||||
|
||||
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
|
||||
///
|
||||
/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로
|
||||
|
||||
@@ -29,14 +29,23 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
this._backend, {
|
||||
StepCountProbe stepCount = deviceStepCount,
|
||||
PlaceProbe placeFix = devicePlaceFix,
|
||||
ShakeProbe shakes = deviceShakes,
|
||||
WalkBaseline? baseline,
|
||||
}) : _stepCount = stepCount,
|
||||
_placeFix = placeFix,
|
||||
_shakes = shakes,
|
||||
_baseline = baseline ?? WalkBaseline();
|
||||
|
||||
final Backend _backend;
|
||||
final StepCountProbe _stepCount;
|
||||
final PlaceProbe _placeFix;
|
||||
final ShakeProbe _shakes;
|
||||
|
||||
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
|
||||
ShakeJudge? _shakeRun;
|
||||
|
||||
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
|
||||
static const defaultBpm = 100.0;
|
||||
|
||||
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
||||
final WalkBaseline _baseline;
|
||||
@@ -157,6 +166,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final steps = type == QuestType.steps;
|
||||
final goal = steps
|
||||
? tiers[count.clamp(0, tiers.length - 1)]
|
||||
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
|
||||
: type == QuestType.musicShake
|
||||
? quest.intOr('totalSegments', dailyLimit)
|
||||
: dailyLimit;
|
||||
|
||||
return QuestCard(
|
||||
@@ -175,7 +187,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
: QuestStage.notStarted,
|
||||
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
|
||||
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
||||
progress: steps ? (done ? goal : 0) : count,
|
||||
progress: type.gathers ? (done ? goal : 0) : count,
|
||||
rewardPop: type.rewardFrom(quest, policy),
|
||||
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
|
||||
participants: quest.intOr('participantCount'),
|
||||
@@ -435,6 +447,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type == QuestType.musicShake) return _shakeProgress(card);
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
||||
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
||||
@@ -460,6 +473,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
||||
QuestType.timeAttack =>
|
||||
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
|
||||
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
||||
QuestType.streaming =>
|
||||
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
|
||||
QuestType.musicShake => await _verifyShake(card),
|
||||
_ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''),
|
||||
};
|
||||
|
||||
@@ -502,6 +519,53 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
|
||||
/// 흔들기는 그동안만 모은다.
|
||||
Stream<int> _shakeProgress(QuestCard card) async* {
|
||||
_shakeRun = null;
|
||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final segments = quest.intOr('totalSegments');
|
||||
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
|
||||
? (quest['bpm'] as num).toDouble()
|
||||
: defaultBpm;
|
||||
if (segments <= 0) {
|
||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||
}
|
||||
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
|
||||
final sub = _shakes().listen(run.add, onError: (_) {});
|
||||
try {
|
||||
for (var i = 1; i < segments; i++) {
|
||||
await Future<void>.delayed(run.beat);
|
||||
yield i;
|
||||
}
|
||||
await Future<void>.delayed(run.end.difference(DateTime.now()));
|
||||
_shakeRun = run;
|
||||
yield segments;
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
||||
final run = _shakeRun;
|
||||
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
|
||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final ids = quest['trackIds'];
|
||||
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
|
||||
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
|
||||
_shakeRun = null;
|
||||
return _backend.call('fnQuestVerifyMusicShake', {
|
||||
'questId': card.id,
|
||||
'trackId': trackId,
|
||||
'totalSegments': run.segments,
|
||||
'judgement': run.judgement(),
|
||||
'occurredAt': run.end.toUtc().toIso8601String(),
|
||||
...await _fandomField(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
|
||||
final quest = await _backend.db
|
||||
.doc('quests/${card.id}')
|
||||
|
||||
@@ -758,6 +758,8 @@
|
||||
"questPoolHelper": "Deposit {pool} POP · Fee {fee} POP",
|
||||
"questCreateCost": "{title}\nReward {reward} POP × {count} = {pool} POP\nFee {fee} POP (burned on publishing)\n{total} POP in total will be deducted.",
|
||||
"questNoReward": "This quest has no reward.",
|
||||
"questStreamJoined": "Your participation is recorded. This quest has no POP reward.",
|
||||
"questNoShake": "No shakes recorded. Tap Start and shake to the beat.",
|
||||
"questDeadline": "Deadline",
|
||||
"questRemainingHours": "{hours}h left",
|
||||
"questEnded": "Ended",
|
||||
|
||||
@@ -758,6 +758,8 @@
|
||||
"questPoolHelper": "Depósito {pool} POP · Comisión {fee} POP",
|
||||
"questCreateCost": "{title}\nRecompensa {reward} POP × {count} = {pool} POP\nComisión {fee} POP (se quema al publicar)\nSe descontarán {total} POP en total.",
|
||||
"questNoReward": "Esta misión no tiene recompensa.",
|
||||
"questStreamJoined": "Tu participación quedó registrada. Esta misión no otorga POP.",
|
||||
"questNoShake": "No se registraron sacudidas. Pulsa Empezar y agita al ritmo.",
|
||||
"questDeadline": "Fecha límite",
|
||||
"questRemainingHours": "Quedan {hours} h",
|
||||
"questEnded": "Finalizada",
|
||||
|
||||
@@ -758,6 +758,8 @@
|
||||
"questPoolHelper": "合計 {pool} POP 預け入れ・手数料 {fee} POP",
|
||||
"questCreateCost": "{title}\n報酬 {reward} POP × {count}人 = {pool} POP\n手数料 {fee} POP(公開時に消滅)\n合計 {total} POP が差し引かれます。",
|
||||
"questNoReward": "報酬のない参加クエストです。",
|
||||
"questStreamJoined": "参加を記録しました。このクエストにPOP報酬はありません。",
|
||||
"questNoShake": "シェイクの記録がありません。スタートを押してビートに合わせて振ってください。",
|
||||
"questDeadline": "締切",
|
||||
"questRemainingHours": "残り{hours}時間",
|
||||
"questEnded": "終了",
|
||||
|
||||
@@ -1560,6 +1560,8 @@
|
||||
}
|
||||
},
|
||||
"questNoReward": "무보상 참여 퀘스트예요.",
|
||||
"questStreamJoined": "참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.",
|
||||
"questNoShake": "흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.",
|
||||
"questDeadline": "마감",
|
||||
"questRemainingHours": "{hours}시간 남음",
|
||||
"@questRemainingHours": {
|
||||
|
||||
@@ -4664,6 +4664,18 @@ abstract class AppLocalizations {
|
||||
/// **'무보상 참여 퀘스트예요.'**
|
||||
String get questNoReward;
|
||||
|
||||
/// No description provided for @questStreamJoined.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.'**
|
||||
String get questStreamJoined;
|
||||
|
||||
/// No description provided for @questNoShake.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.'**
|
||||
String get questNoShake;
|
||||
|
||||
/// No description provided for @questDeadline.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
|
||||
@@ -2644,6 +2644,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => 'This quest has no reward.';
|
||||
|
||||
@override
|
||||
String get questStreamJoined =>
|
||||
'Your participation is recorded. This quest has no POP reward.';
|
||||
|
||||
@override
|
||||
String get questNoShake =>
|
||||
'No shakes recorded. Tap Start and shake to the beat.';
|
||||
|
||||
@override
|
||||
String get questDeadline => 'Deadline';
|
||||
|
||||
|
||||
@@ -2655,6 +2655,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => 'Esta misión no tiene recompensa.';
|
||||
|
||||
@override
|
||||
String get questStreamJoined =>
|
||||
'Tu participación quedó registrada. Esta misión no otorga POP.';
|
||||
|
||||
@override
|
||||
String get questNoShake =>
|
||||
'No se registraron sacudidas. Pulsa Empezar y agita al ritmo.';
|
||||
|
||||
@override
|
||||
String get questDeadline => 'Fecha límite';
|
||||
|
||||
|
||||
@@ -2556,6 +2556,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => '報酬のない参加クエストです。';
|
||||
|
||||
@override
|
||||
String get questStreamJoined => '参加を記録しました。このクエストにPOP報酬はありません。';
|
||||
|
||||
@override
|
||||
String get questNoShake => 'シェイクの記録がありません。スタートを押してビートに合わせて振ってください。';
|
||||
|
||||
@override
|
||||
String get questDeadline => '締切';
|
||||
|
||||
|
||||
@@ -2558,6 +2558,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => '무보상 참여 퀘스트예요.';
|
||||
|
||||
@override
|
||||
String get questStreamJoined => '참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.';
|
||||
|
||||
@override
|
||||
String get questNoShake => '흔들기 기록이 없어요. 시작하기를 누르고 박자에 맞춰 흔들어 주세요.';
|
||||
|
||||
@override
|
||||
String get questDeadline => '마감';
|
||||
|
||||
|
||||
@@ -2553,6 +2553,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => '这是无奖励的参与任务。';
|
||||
|
||||
@override
|
||||
String get questStreamJoined => '已记录参与。此任务没有 POP 奖励。';
|
||||
|
||||
@override
|
||||
String get questNoShake => '没有摇动记录。请点击开始并跟着节拍摇动。';
|
||||
|
||||
@override
|
||||
String get questDeadline => '截止';
|
||||
|
||||
|
||||
@@ -758,6 +758,8 @@
|
||||
"questPoolHelper": "共存入 {pool} POP · 手续费 {fee} POP",
|
||||
"questCreateCost": "{title}\n奖励 {reward} POP × {count}人 = {pool} POP\n手续费 {fee} POP(发布时销毁)\n共扣除 {total} POP。",
|
||||
"questNoReward": "这是无奖励的参与任务。",
|
||||
"questStreamJoined": "已记录参与。此任务没有 POP 奖励。",
|
||||
"questNoShake": "没有摇动记录。请点击开始并跟着节拍摇动。",
|
||||
"questDeadline": "截止",
|
||||
"questRemainingHours": "剩余{hours}小时",
|
||||
"questEnded": "已截止",
|
||||
|
||||
@@ -1834,6 +1834,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questNoReward => _remote['questNoReward'] ?? _base.questNoReward;
|
||||
|
||||
@override
|
||||
String get questNoShake => _remote['questNoShake'] ?? _base.questNoShake;
|
||||
|
||||
@override
|
||||
String get questNoSteps => _remote['questNoSteps'] ?? _base.questNoSteps;
|
||||
|
||||
@@ -1927,6 +1930,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questStopConfirm => _remote['questStopConfirm'] ?? _base.questStopConfirm;
|
||||
|
||||
@override
|
||||
String get questStreamJoined => _remote['questStreamJoined'] ?? _base.questStreamJoined;
|
||||
|
||||
@override
|
||||
String get questSubmit => _remote['questSubmit'] ?? _base.questSubmit;
|
||||
|
||||
|
||||
@@ -310,6 +310,22 @@ void main() {
|
||||
final snapshot = await FirestoreGalaxyRepository(_backend(db)).load();
|
||||
final rank = {for (final p in snapshot.planets) p.id: p.rank};
|
||||
expect(rank, {'bts': 1, 'svt': 2, 'neb': 3});
|
||||
expect(snapshot.planets.firstWhere((p) => p.id == 'neb').rankLabel, '-');
|
||||
});
|
||||
|
||||
// 원장 B-17 — 최근 7일 유효 활동이 없는 정식 행성(서버 rankingEligible: false)도 순위 밖이다.
|
||||
test('최근 7일 활동이 없는 행성은 순위 없이 바깥 궤도에 서고 Ranking View 중심이 되지 않는다', () async {
|
||||
final db = await _seed();
|
||||
await db.doc('fandoms/idle').set({
|
||||
'name': '휴면', 'stage': 2, 'fpiScore': 999999, 'rankingEligible': false,
|
||||
});
|
||||
await db.doc('fandoms/svt').set({
|
||||
'name': 'SEVENTEEN', 'stage': 1, 'fpiScore': 120000, 'rankingEligible': true,
|
||||
});
|
||||
final snapshot = await FirestoreGalaxyRepository(_backend(db)).load();
|
||||
final rank = {for (final p in snapshot.planets) p.id: (p.rank, p.rankLabel)};
|
||||
expect(rank, {'bts': (1, '1'), 'svt': (2, '2'), 'idle': (3, '-')});
|
||||
expect(snapshot.rankingView.planets.firstWhere((p) => p.isCenter).id, 'bts');
|
||||
});
|
||||
|
||||
test('대표 팬덤은 primaryFandomId 가 정본이고 가입 목록은 최신 가입순이다(C-15 · §2.58)', () async {
|
||||
@@ -466,13 +482,12 @@ void main() {
|
||||
expect((await repo.load(nebula: false)).totalFpi, 300000);
|
||||
});
|
||||
|
||||
test('내 기여 상위 집계가 없으면 Mock 사유를 화면에 넘긴다 — 대조표 3-5', () async {
|
||||
test('서버가 내 기여 상위 % 를 적지 않은 축은 null 이다 — 「상위 100%」를 짓지 않는다', () async {
|
||||
final db = await seedPlanet();
|
||||
await db.doc('fandoms/bts/members/$_uid')
|
||||
.set({'nickname': '나'});
|
||||
final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
|
||||
.load(nebula: false);
|
||||
expect(detail.mockNote, isNotNull);
|
||||
expect(detail.scores.every((s) => s.myTopPercent == null), isTrue);
|
||||
});
|
||||
|
||||
@@ -551,6 +566,24 @@ void main() {
|
||||
expect(referral.mine!.rank, 1);
|
||||
});
|
||||
|
||||
test('사용자 순위 · 순위 변동은 서버 5분 집계(rankByAxis · rankPrevByAxis)를 읽는다', () async {
|
||||
final db = await seedPlanet();
|
||||
await db.doc('fandoms/bts/members/other').set({
|
||||
'rankByAxis': {'bpm': 1}, 'rankPrevByAxis': {'bpm': 2},
|
||||
}, SetOptions(merge: true));
|
||||
await db.doc('fandoms/bts/members/$_uid').set({
|
||||
'rankByAxis': {'bpm': 2}, 'rankPrevByAxis': {'bpm': 1},
|
||||
}, SetOptions(merge: true));
|
||||
final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts');
|
||||
final bpm = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts');
|
||||
expect(bpm.rows.map((r) => (r.name, r.rank, r.change)).toList(), [
|
||||
('버니즈', 1, 1),
|
||||
('나', 2, -1),
|
||||
]);
|
||||
expect(bpm.mine!.change, -1);
|
||||
expect(bpm.mockNote, isNull);
|
||||
});
|
||||
|
||||
test('국가 랭킹은 사전 집계 1건을 읽고, 없으면 Mock 이라고 알린다', () async {
|
||||
final db = await seedPlanet();
|
||||
final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts');
|
||||
|
||||
@@ -309,14 +309,14 @@ void main() {
|
||||
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 사용자 문구로만 알린다', (tester) async {
|
||||
testWidgets('스트리밍은 무보상 참여로 열려 있고 보상 · 광고 화면 없이 참여만 알린다(C-04)', (tester) async {
|
||||
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
|
||||
await tester.pump();
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, '준비 중입니다.'),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
expect(find.text('이 퀘스트는 아직 참여할 수 없습니다.'), findsOneWidget);
|
||||
expect(find.text('이 퀘스트는 아직 참여할 수 없습니다.'), findsNothing);
|
||||
await tester.tap(find.widgetWithText(FilledButton, '퀘스트 시작하기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.'), findsOneWidget);
|
||||
expect(find.text('기본 보상 받기'), findsNothing);
|
||||
// 내부 질의 번호 · 「발주사」 · MOCK 은 화면에 내보내지 않는다.
|
||||
expect(find.textContaining('Q-QC-'), findsNothing);
|
||||
expect(find.textContaining('발주사'), findsNothing);
|
||||
|
||||
@@ -320,16 +320,67 @@ service cloud.firestore {
|
||||
expect(backend.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('회신 대기 유형은 어느 함수도 부르지 않는다', () async {
|
||||
test('시청 · 스트리밍은 무보상 참여 함수만 부른다(C-04) — 보상은 0', () async {
|
||||
final db = await _seed();
|
||||
final backend = _RecordingBackend(db);
|
||||
final backend = _RecordingBackend(db, reply: const {'rewarded': 0, 'remaining': 0});
|
||||
final repo = FirestoreQuestRepository(backend);
|
||||
final cards = await repo.load();
|
||||
final card = _card(cards, 'stream');
|
||||
final card = _card(await repo.load(), 'stream');
|
||||
|
||||
expect(card.type.verifiable, isFalse);
|
||||
expect(card.type.verifiable, isTrue);
|
||||
// 문서에 rewardPop 10 이 남아 있어도 화면에 보상을 약속하지 않는다.
|
||||
expect(card.rewardPop, 0);
|
||||
final reward = await repo.verify(card);
|
||||
expect(backend.calls.single.name, 'fnQuestStreamParticipate');
|
||||
expect(backend.calls.single.payload, {'questId': 'stream'});
|
||||
expect(reward.rewardedPop, 0);
|
||||
});
|
||||
|
||||
test('Music Shake — 박마다 구간을 세고 판정표 · 곡 ID 를 서버에 보낸다', () async {
|
||||
final db = await _seed();
|
||||
// 600 BPM = 박 100ms · 3 구간. 박 0 과 박 1 근처에서만 흔든다.
|
||||
await db.doc('quests/shake').set({
|
||||
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
|
||||
'trackIds': ['track-a'], 'totalSegments': 3, 'bpm': 600,
|
||||
});
|
||||
final backend = _RecordingBackend(db);
|
||||
final repo = FirestoreQuestRepository(
|
||||
backend,
|
||||
shakes: () async* {
|
||||
final start = DateTime.now();
|
||||
yield start;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
yield start.add(const Duration(milliseconds: 140));
|
||||
},
|
||||
);
|
||||
final card = _card(await repo.load(), 'shake');
|
||||
expect(card.type.verifiable, isTrue);
|
||||
expect(card.goal, 3);
|
||||
expect(card.progress, 0);
|
||||
|
||||
// 판을 끝내기 전에는 서버를 부르지 않는다.
|
||||
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
||||
expect(backend.calls, isEmpty);
|
||||
|
||||
expect(await repo.progressStream(card).toList(), [1, 2, 3]);
|
||||
await repo.verify(card);
|
||||
final call = backend.calls.single;
|
||||
expect(call.name, 'fnQuestVerifyMusicShake');
|
||||
expect(call.payload['questId'], 'shake');
|
||||
expect(call.payload['trackId'], 'track-a');
|
||||
expect(call.payload['totalSegments'], 3);
|
||||
final j = call.payload['judgement'] as Map;
|
||||
expect(j['perfect']! + j['good']! + j['miss']!, 3);
|
||||
expect(j['miss'], 1);
|
||||
});
|
||||
|
||||
test('ShakeJudge — 박과의 거리로 perfect · good · miss 를 가르고 최장 연속을 센다', () {
|
||||
final start = DateTime(2026, 10, 5);
|
||||
final judge = ShakeJudge(bpm: 60, segments: 4, start: start)
|
||||
..add(start.add(const Duration(milliseconds: 100))) // 박 0 · perfect
|
||||
..add(start.add(const Duration(milliseconds: 1400))) // 박 1 · good
|
||||
..add(start.add(const Duration(milliseconds: 3000))); // 박 3 · perfect, 박 2 miss
|
||||
expect(judge.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
|
||||
expect(judge.end, start.add(const Duration(milliseconds: 3500)));
|
||||
});
|
||||
|
||||
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
|
||||
|
||||
Reference in New Issue
Block a user