행성 Level 1~100 수치표 적용 · 행성 컬러 마스터
This commit is contained in:
@@ -51,8 +51,23 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
|||||||
|
|
||||||
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
|
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
|
||||||
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
|
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
|
||||||
|
/**
|
||||||
|
* 컬러 **기본 목록** — Figma 「행성 색 선택」 명세 `2066:17910`(흰 · 검 · 하늘 · 초록 · 노랑 · 주황 · 빨강 · 보라).
|
||||||
|
* 10/3 통합회신 #6 「최신 Figma 팬덤컬러를 기본으로 하고 발주사가 컬러 항목을 추가할 수 있는 구조, 특정 8색 고정 적용
|
||||||
|
* 안 함」에 따라 실제 허용 목록은 컬러 마스터 `config/planetColors.colors[{key, hex, visible}]` 가 정한다
|
||||||
|
* (Firestore 설계서 §2.17 · C-115). 마스터가 없을 때만 이 목록을 쓴다.
|
||||||
|
*/
|
||||||
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
|
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
|
||||||
|
|
||||||
|
/** 생성 화면이 고를 수 있는(노출 중인) 컬러 키 — 마스터의 `visible !== false` 항목. */
|
||||||
|
export async function planetColorKeys(): Promise<string[]> {
|
||||||
|
const raw = (await getFirestore().doc('config/planetColors').get()).get('colors');
|
||||||
|
if (!Array.isArray(raw)) return PLANET_COLORS;
|
||||||
|
const keys = raw.filter((c) => c && typeof c.key === 'string' && c.key && c.visible !== false)
|
||||||
|
.map((c) => c.key as string);
|
||||||
|
return keys.length > 0 ? keys : PLANET_COLORS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
|
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
|
||||||
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
||||||
@@ -344,7 +359,7 @@ export const fnPlanetCreate = onCall(async (req) => {
|
|||||||
const fandomId = str(req.data, 'fandomId');
|
const fandomId = str(req.data, 'fandomId');
|
||||||
const planetType = str(req.data, 'planetType');
|
const planetType = str(req.data, 'planetType');
|
||||||
const colorKey = str(req.data, 'colorKey');
|
const colorKey = str(req.data, 'colorKey');
|
||||||
if (!PLANET_SHAPES.includes(planetType) || !PLANET_COLORS.includes(colorKey)) {
|
if (!PLANET_SHAPES.includes(planetType) || !(await planetColorKeys()).includes(colorKey)) {
|
||||||
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
|
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
@@ -375,10 +390,18 @@ export const fnPlanetCreate = onCall(async (req) => {
|
|||||||
colorKey,
|
colorKey,
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
// 수치표 v1.4 「레벨 진행 기준점」 — 생성 시점 누적 POP 을 저장하고 이후 증가분부터 Lv.1→2 진행도에
|
||||||
|
// 반영한다. 성운 기여 POP 은 growthPop 에 그대로 남는다(「소멸·초기화 없음」).
|
||||||
|
const birthPop = Number(fandom.growthPop ?? 0);
|
||||||
tx.update(fandomRef, {
|
tx.update(fandomRef, {
|
||||||
stage: 1,
|
stage: 1,
|
||||||
|
level: 1,
|
||||||
|
levelPop: 0,
|
||||||
|
growthPopAtBirth: birthPop,
|
||||||
|
levelCountedGrowthPop: birthPop,
|
||||||
planetType,
|
planetType,
|
||||||
colorKey,
|
colorKey,
|
||||||
|
stageChangedAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { callerUid, requireRole, str } from '../core/callable.js';
|
|||||||
import { ERR, fail } from '../core/errors.js';
|
import { ERR, fail } from '../core/errors.js';
|
||||||
import { sendFandomPush } from '../notify/push.js';
|
import { sendFandomPush } from '../notify/push.js';
|
||||||
import { awardBadge } from '../account/passport.js';
|
import { awardBadge } from '../account/passport.js';
|
||||||
|
import { cumulativeFor, loadGrowthConfig, MAX_PLANET_LEVEL, planetLevelOf, stageOfLevel } from './planet_level.js';
|
||||||
|
|
||||||
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존하고, 진화 주기 기여자에게 기념 Emblem 을 준다(상세정의 22장). */
|
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존하고, 진화 주기 기여자에게 기념 Emblem 을 준다(상세정의 22장). */
|
||||||
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||||
@@ -101,29 +102,21 @@ const FINAL_STAGE = 6;
|
|||||||
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
|
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**.
|
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 원장 **B-11 · B-53 · B-54**.
|
||||||
*
|
*
|
||||||
* - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값
|
* - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`,
|
||||||
* (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다.
|
* 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다.
|
||||||
* 하나만 채우면 그대로 대기한다.
|
* - Level = 진행도와 sFPI 가 **둘 다** 도달한 가장 높은 Level(동시 충족 · Carry-over). Stage = Level 구간.
|
||||||
* - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서
|
* - 강등하지 않는다(B-53 「Stage 강등 0건」) — Level 도 내리지 않는다. Stage6(Lv.100)이 마지막이다.
|
||||||
* 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다.
|
* - 앱이 HOME · 상세에 그리도록 다음 Level · 다음 Stage 진입 누계를 함께 적는다.
|
||||||
* - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다.
|
|
||||||
* - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어
|
|
||||||
* 승급시키지 않는다.
|
|
||||||
*
|
*
|
||||||
* 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등).
|
* 증가분을 반영한 뒤 `levelCountedGrowthPop` 을 옮기므로 하루에 여러 번 돌아도 두 번 세지 않는다(멱등).
|
||||||
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다.
|
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` · `level` 상승을 보고 띄운다.
|
||||||
*/
|
*/
|
||||||
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
|
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
const stageDocs = await db.collection('planetStages').get();
|
const cfg = await loadGrowthConfig();
|
||||||
const required = new Map<number, { pop: number; sfpi: number }>();
|
const maxWeight = Math.max(1, ...cfg.zapWeightBands.map(([, w]) => w));
|
||||||
for (const doc of stageDocs.docs) {
|
|
||||||
const pop = doc.get('requiredPop');
|
|
||||||
const sfpi = doc.get('requiredSfpi');
|
|
||||||
if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi });
|
|
||||||
}
|
|
||||||
|
|
||||||
const refs = fandomIds
|
const refs = fandomIds
|
||||||
? fandomIds.map((id) => db.doc(`fandoms/${id}`))
|
? fandomIds.map((id) => db.doc(`fandoms/${id}`))
|
||||||
@@ -135,16 +128,29 @@ export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]>
|
|||||||
const snap = await tx.get(ref);
|
const snap = await tx.get(ref);
|
||||||
const from = snap.get('stage');
|
const from = snap.get('stage');
|
||||||
if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null;
|
if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null;
|
||||||
const pop = Number(snap.get('growthPop') ?? 0);
|
const growthPop = Number(snap.get('growthPop') ?? 0);
|
||||||
|
// 기준점이 없는 기존 행성은 0 — 생성 시점을 알 수 없어 전체 누계를 진행도로 본다(이관 대상).
|
||||||
|
const counted = Number(snap.get('levelCountedGrowthPop') ?? snap.get('growthPopAtBirth') ?? 0);
|
||||||
|
const rawWeight = Number(snap.get('zapWeight') ?? 1);
|
||||||
|
const weight = Number.isFinite(rawWeight) ? Math.min(maxWeight, Math.max(1, rawWeight)) : 1;
|
||||||
|
const delta = growthPop - counted;
|
||||||
|
// 회수(reverseGrowth)로 줄어든 몫은 배율 없이 뺀다.
|
||||||
|
const levelPop = Math.max(0, Number(snap.get('levelPop') ?? 0) + (delta > 0 ? delta * weight : delta));
|
||||||
const sfpi = Number(snap.get('sfpi') ?? 0);
|
const sfpi = Number(snap.get('sfpi') ?? 0);
|
||||||
let to = from as number;
|
const prevLevel = Number.isInteger(snap.get('level')) ? snap.get('level') as number : 1;
|
||||||
for (let next = to + 1; next <= FINAL_STAGE; next++) {
|
const level = Math.max(prevLevel, planetLevelOf(levelPop, sfpi, cfg.steps));
|
||||||
const req = required.get(next);
|
const to = Math.min(FINAL_STAGE, Math.max(from as number, stageOfLevel(level, cfg.stageLevelFrom)));
|
||||||
if (!req || pop < req.pop || sfpi < req.sfpi) break;
|
const [nextLevelPop, nextLevelSfpi] = cumulativeFor(level + 1, cfg.steps);
|
||||||
to = next;
|
const nextStageFrom = cfg.stageLevelFrom[to + 1];
|
||||||
}
|
const [nextStagePop, nextStageSfpi] = nextStageFrom ? cumulativeFor(nextStageFrom, cfg.steps) : [null, null];
|
||||||
|
tx.update(ref, {
|
||||||
|
level, levelPop, levelCountedGrowthPop: growthPop,
|
||||||
|
nextLevelPop: level < MAX_PLANET_LEVEL ? nextLevelPop : null,
|
||||||
|
nextLevelSfpi: level < MAX_PLANET_LEVEL ? nextLevelSfpi : null,
|
||||||
|
nextStagePop, nextStageSfpi,
|
||||||
|
...(to !== from ? { stage: to, stageChangedAt: FieldValue.serverTimestamp() } : {}),
|
||||||
|
});
|
||||||
if (to === from) return null;
|
if (to === from) return null;
|
||||||
tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() });
|
|
||||||
return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined };
|
return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined };
|
||||||
});
|
});
|
||||||
if (!result) continue;
|
if (!result) continue;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { txIdOf } from '../core/ids.js';
|
|||||||
import { dayKey } from '../core/time.js';
|
import { dayKey } from '../core/time.js';
|
||||||
import { notifyEclipseWarning } from '../notify/push.js';
|
import { notifyEclipseWarning } from '../notify/push.js';
|
||||||
import { promoteStages } from './evolution.js';
|
import { promoteStages } from './evolution.js';
|
||||||
|
import { loadGrowthConfig, zapWeightOf } from './planet_level.js';
|
||||||
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -881,6 +882,7 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
|||||||
const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`));
|
const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`));
|
||||||
const to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000);
|
const to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000);
|
||||||
const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get();
|
const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get();
|
||||||
|
const growth = await loadGrowthConfig();
|
||||||
|
|
||||||
for (const fandom of fandoms.docs) {
|
for (const fandom of fandoms.docs) {
|
||||||
const fpiAxes = (fandom.get('fpiAxes') as Record<FpiAxis, number> | undefined)
|
const fpiAxes = (fandom.get('fpiAxes') as Record<FpiAxis, number> | undefined)
|
||||||
@@ -891,10 +893,23 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
|||||||
// 사용자 문서(`fandomDailyStats/{day}/users/{uid}`)를 함께 써서 개수만 세도록 옮긴다.
|
// 사용자 문서(`fandomDailyStats/{day}/users/{uid}`)를 함께 써서 개수만 세도록 옮긴다.
|
||||||
const events = await fandom.ref.collection('fpiEvents')
|
const events = await fandom.ref.collection('fpiEvents')
|
||||||
.where('occurredAt', '>=', from).where('occurredAt', '<', to).get();
|
.where('occurredAt', '>=', from).where('occurredAt', '<', to).get();
|
||||||
const users = new Set(events.docs
|
const active = (e: FirebaseFirestore.QueryDocumentSnapshot) => e.get('verified') === true &&
|
||||||
.filter((e) => e.get('verified') === true && typeof e.get('uid') === 'string'
|
typeof e.get('uid') === 'string' && e.get('dauEligible') !== false;
|
||||||
&& e.get('dauEligible') !== false)
|
const users = new Set(events.docs.filter(active).map((e) => e.get('uid') as string));
|
||||||
|
// 수치표 v1.4 「ZAP 참여자 집계 — 일 1회 이상 유효 ZAP 한 고유 사용자」 ÷ 「최근 30일 Active 팬덤 사용자」.
|
||||||
|
// 🟡 Active 의 정의는 자료에 없다(해석 확인 요청 영역A Q-A03) — DAU 와 같은 「서버 인정 활동 1건 이상」.
|
||||||
|
// ponytail: 30일 이벤트 전수 조회. 팬덤이 커지면 일별 고유 사용자 문서로 옮긴다.
|
||||||
|
const zapUsers = new Set(events.docs.filter((e) => active(e) && e.get('activity') === 'zap')
|
||||||
.map((e) => e.get('uid') as string));
|
.map((e) => e.get('uid') as string));
|
||||||
|
const activeSince = Timestamp.fromMillis(to.toMillis() - growth.activeDays * 86_400_000);
|
||||||
|
const activeUsers = new Set((await fandom.ref.collection('fpiEvents')
|
||||||
|
.where('occurredAt', '>=', activeSince).where('occurredAt', '<', to).select('uid', 'verified', 'dauEligible').get())
|
||||||
|
.docs.filter(active).map((e) => e.get('uid') as string));
|
||||||
|
const zapRate = activeUsers.size > 0 ? zapUsers.size / activeUsers.size : 0;
|
||||||
|
const recent = await fandom.ref.collection('fandomDailyStats')
|
||||||
|
.where('day', '<', day).orderBy('day', 'desc').limit(growth.zapRateDays - 1).get();
|
||||||
|
const rates = [zapRate, ...recent.docs.map((d) => Number(d.get('zapRate') ?? 0))];
|
||||||
|
const zapRateAvg = rates.reduce((a, b) => a + b, 0) / rates.length;
|
||||||
|
|
||||||
// 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다.
|
// 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다.
|
||||||
await db.runTransaction(async (tx) => {
|
await db.runTransaction(async (tx) => {
|
||||||
@@ -903,8 +918,14 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
|||||||
const snapshot = await tx.get(snapshotRef);
|
const snapshot = await tx.get(snapshotRef);
|
||||||
// DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다.
|
// DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다.
|
||||||
tx.set(statsRef, {
|
tx.set(statsRef, {
|
||||||
day, dau: users.size, source: 'fpiEvents', computedAt: FieldValue.serverTimestamp(),
|
day, dau: users.size, source: 'fpiEvents',
|
||||||
|
zapUsers: zapUsers.size, activeUsers: activeUsers.size, zapRate,
|
||||||
|
computedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
// 성장 가중치 — 최근 [zapRateDays]일 평균 참여율의 구간 배율. 레벨업 POP 진행도에만 쓴다(promoteStages).
|
||||||
|
tx.set(fandom.ref, {
|
||||||
|
zapRate7: zapRateAvg, zapWeight: zapWeightOf(zapRateAvg, growth.zapWeightBands),
|
||||||
|
}, { merge: true });
|
||||||
if (snapshot.exists) return;
|
if (snapshot.exists) return;
|
||||||
tx.create(snapshotRef, {
|
tx.create(snapshotRef, {
|
||||||
fpiAxes,
|
fpiAxes,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planet Level Lv.1~100 · 레벨 진행 기준점 · ZAP 참여도 성장 가중치 —
|
||||||
|
* 발주사 **Q2 행성성장 레벨별 수치표 v1.4**(2026-08-25) 그대로.
|
||||||
|
*
|
||||||
|
* - 「Level Detail」 Lv.n → n+1 필요 POP · sFPI(99행). **둘 다** 채워야 오르고(「Level-up 조건 POP + sFPI 동시
|
||||||
|
* 충족」), 초과분은 다음 Level 로 이월(「Carry-over 적용」) — 누계로 판정하면 둘이 자연히 성립한다.
|
||||||
|
* - 「Stage Summary」 Level 구간 — 시작 Lv.1~20 · 성장 21~40 · 함께 41~60 · 동행 61~80 · 빛 81~99 · 영원 100.
|
||||||
|
* 상세정의 3장의 Lv.1/10/25/40/55/75/100 과 다르며, 최신 서면인 수치표를 따른다(영역A 확인 통보 N-A01).
|
||||||
|
* - 「Policy Params」 레벨 진행 기준점 — 행성 생성 시점 누적 POP 을 저장하고 이후 증가분부터 진행도에 반영.
|
||||||
|
* - 「Policy Params」 ZAP 참여도 가중치 ×1.00~×1.50(7구간) — **레벨업 POP 진행도에만** 적용, 원장 · 잔액 ·
|
||||||
|
* 누적 기여 POP · sFPI 에는 적용하지 않는다.
|
||||||
|
* - 「Admin 조정 가능」 — `planetLevels/{n}`(n → n+1 필요값) · `config/planetGrowth` 가 덮는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Level Detail — index i 는 Lv.(i+1) → Lv.(i+2) 의 [필요 POP, 필요 sFPI]. */
|
||||||
|
export const PLANET_LEVEL_STEPS: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[3000, 610], [3250, 630], [3500, 650], [3750, 670], [4000, 690], [4250, 710], [4500, 730], [4750, 750],
|
||||||
|
[5000, 770], [5250, 790], [5500, 810], [5750, 830], [6000, 850], [6250, 870], [6500, 890], [6750, 910],
|
||||||
|
[7000, 930], [7250, 950], [7750, 960], [10500, 1575], [11500, 1725], [12500, 1875], [13500, 2025],
|
||||||
|
[14500, 2175], [15500, 2325], [16500, 2475], [17500, 2625], [18500, 2775], [19500, 2925], [20500, 3075],
|
||||||
|
[21500, 3225], [22500, 3375], [23500, 3525], [24500, 3675], [25500, 3825], [26500, 3975], [27500, 4125],
|
||||||
|
[28500, 4275], [29500, 4425], [37000, 6500], [41000, 7000], [45000, 7500], [49000, 8000], [53000, 8500],
|
||||||
|
[57000, 9000], [61000, 9500], [65000, 10000], [69000, 10500], [73000, 11000], [77000, 11500],
|
||||||
|
[81000, 12000], [85000, 12500], [89000, 13000], [93000, 13500], [97000, 14000], [101000, 14500],
|
||||||
|
[105000, 15000], [109000, 15500], [113000, 16000], [210000, 31500], [230000, 34500], [250000, 37500],
|
||||||
|
[270000, 40500], [290000, 43500], [310000, 46500], [330000, 49500], [350000, 52500], [370000, 55500],
|
||||||
|
[390000, 58500], [410000, 61500], [430000, 64500], [450000, 67500], [470000, 70500], [490000, 73500],
|
||||||
|
[510000, 76500], [530000, 79500], [550000, 82500], [570000, 85500], [590000, 88500], [1050000, 157500],
|
||||||
|
[1150000, 172500], [1250000, 187500], [1350000, 202500], [1450000, 217500], [1550000, 232500],
|
||||||
|
[1650000, 247500], [1750000, 262500], [1850000, 277500], [1950000, 292500], [2050000, 307500],
|
||||||
|
[2150000, 322500], [2250000, 337500], [2350000, 352500], [2450000, 367500], [2550000, 382500],
|
||||||
|
[2650000, 397500], [2750000, 412500], [2850000, 427500], [2950000, 442500],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAX_PLANET_LEVEL = 100;
|
||||||
|
|
||||||
|
/** Stage Summary 의 Level 구간 시작 — Stage n 은 Lv.[n] 부터. */
|
||||||
|
export const STAGE_LEVEL_FROM: Readonly<Record<number, number>> = { 1: 1, 2: 21, 3: 41, 4: 61, 5: 81, 6: 100 };
|
||||||
|
|
||||||
|
/** Policy Params 「최근 7일 평균 ZAP 참여율 → 성장 가중치」. [하한 참여율, 배율]. */
|
||||||
|
export const ZAP_WEIGHT_BANDS: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[0, 1.0], [0.05, 1.05], [0.10, 1.10], [0.20, 1.20], [0.30, 1.30], [0.40, 1.40], [0.50, 1.50],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface GrowthConfig {
|
||||||
|
steps: ReadonlyArray<readonly [number, number]>;
|
||||||
|
stageLevelFrom: Readonly<Record<number, number>>;
|
||||||
|
zapWeightBands: ReadonlyArray<readonly [number, number]>;
|
||||||
|
/** 참여율 평균 기간(일) — Policy Params 「최근 7일 · Admin 조정」. */
|
||||||
|
zapRateDays: number;
|
||||||
|
/** 분모 Active 기간(일) — Policy Params 「최근 30일 · Admin 조정」. */
|
||||||
|
activeDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 누계로 본 Level — 레벨업 POP 진행도와 sFPI 가 **둘 다** 도달한 가장 높은 Level. */
|
||||||
|
export function planetLevelOf(levelPop: number, sfpi: number, steps = PLANET_LEVEL_STEPS): number {
|
||||||
|
let level = 1;
|
||||||
|
let pop = 0;
|
||||||
|
let s = 0;
|
||||||
|
for (const [needPop, needSfpi] of steps) {
|
||||||
|
pop += needPop;
|
||||||
|
s += needSfpi;
|
||||||
|
if (levelPop < pop || sfpi < s) break;
|
||||||
|
level += 1;
|
||||||
|
}
|
||||||
|
return Math.min(level, MAX_PLANET_LEVEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lv.[level] 에 **도달하는** 누계 [POP, sFPI]. Lv.1 은 [0, 0]. */
|
||||||
|
export function cumulativeFor(level: number, steps = PLANET_LEVEL_STEPS): [number, number] {
|
||||||
|
let pop = 0;
|
||||||
|
let sfpi = 0;
|
||||||
|
for (let i = 0; i < Math.min(level - 1, steps.length); i++) {
|
||||||
|
pop += steps[i]![0];
|
||||||
|
sfpi += steps[i]![1];
|
||||||
|
}
|
||||||
|
return [pop, sfpi];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageOfLevel(level: number, from = STAGE_LEVEL_FROM): number {
|
||||||
|
let stage = 1;
|
||||||
|
for (const [s, lv] of Object.entries(from)) {
|
||||||
|
if (level >= lv && Number(s) > stage) stage = Number(s);
|
||||||
|
}
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zapWeightOf(rate: number, bands = ZAP_WEIGHT_BANDS): number {
|
||||||
|
let weight = 1;
|
||||||
|
for (const [min, w] of bands) if (rate >= min) weight = w;
|
||||||
|
return weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGrowthConfig(): Promise<GrowthConfig> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const [levels, config] = await Promise.all([
|
||||||
|
db.collection('planetLevels').get(), db.doc('config/planetGrowth').get(),
|
||||||
|
]);
|
||||||
|
const steps = PLANET_LEVEL_STEPS.map((step) => [...step] as [number, number]);
|
||||||
|
for (const doc of levels.docs) {
|
||||||
|
const n = Number(doc.id);
|
||||||
|
const pop = doc.get('requiredPop');
|
||||||
|
const sfpi = doc.get('requiredSfpi');
|
||||||
|
if (Number.isInteger(n) && n >= 1 && n < MAX_PLANET_LEVEL &&
|
||||||
|
Number.isFinite(pop) && pop >= 0 && Number.isFinite(sfpi) && sfpi >= 0) {
|
||||||
|
steps[n - 1] = [pop, sfpi];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw = config.data() ?? {};
|
||||||
|
const bands = Array.isArray(raw.zapWeightBands) &&
|
||||||
|
raw.zapWeightBands.every((b: unknown) => Array.isArray(b) && b.length === 2 && b.every(Number.isFinite))
|
||||||
|
? raw.zapWeightBands as Array<[number, number]> : ZAP_WEIGHT_BANDS;
|
||||||
|
const stageLevelFrom = raw.stageLevelFrom && typeof raw.stageLevelFrom === 'object'
|
||||||
|
? raw.stageLevelFrom as Record<number, number> : STAGE_LEVEL_FROM;
|
||||||
|
const positive = (v: unknown, d: number) => (Number.isInteger(v) && (v as number) > 0 ? v as number : d);
|
||||||
|
return {
|
||||||
|
steps, stageLevelFrom, zapWeightBands: bands,
|
||||||
|
zapRateDays: positive(raw.zapRateDays, 7), activeDays: positive(raw.activeDays, 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -274,4 +274,19 @@ describe('행성 탄생', () => {
|
|||||||
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
|
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
|
||||||
.toBe('invalid-argument');
|
.toBe('invalid-argument');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('컬러는 마스터(config/planetColors)가 정한다 — 발주사가 더한 색은 받고 숨긴 색은 받지 않는다(10/3 #6)', async () => {
|
||||||
|
await db().doc('config/planetColors').set({ colors: [
|
||||||
|
{ key: 'gold', hex: '#FFC500', visible: false },
|
||||||
|
{ key: 'mint', hex: '#3EB489', visible: true },
|
||||||
|
] });
|
||||||
|
try {
|
||||||
|
const id = await nebula(10, 1_000);
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('invalid-argument');
|
||||||
|
await call(fnPlanetCreate, { ...pick(id), colorKey: 'mint' });
|
||||||
|
expect(await fandom(id)).toMatchObject({ stage: 1, colorKey: 'mint', level: 1, levelPop: 0 });
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/planetColors').delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||||
import { getFirestore } from 'firebase-admin/firestore';
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { cumulativeFor } from '../src/fandom/planet_level.js';
|
||||||
|
|
||||||
const push = vi.hoisted(() => vi.fn(async () => true));
|
const push = vi.hoisted(() => vi.fn(async () => true));
|
||||||
vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() }));
|
vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() }));
|
||||||
@@ -8,22 +9,18 @@ vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWar
|
|||||||
const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js');
|
const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js');
|
||||||
const { closeDay } = await import('../src/fandom/fandom.js');
|
const { closeDay } = await import('../src/fandom/fandom.js');
|
||||||
|
|
||||||
/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */
|
/**
|
||||||
const STAGES: Array<[number, number, number]> = [
|
* 수치표 v1.4 — Stage 는 Level 구간(시작 Lv.1~20 · 성장 21~). 성장(Stage2) 진입 = Lv.21 누계.
|
||||||
[1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000],
|
* Level Detail Lv.1→20 합 100,000 / 15,000 + Lv.20→21 10,500 / 1,575.
|
||||||
[5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000],
|
*/
|
||||||
];
|
const [LV21_POP, LV21_SFPI] = cumulativeFor(21);
|
||||||
const id = 'test-stage-promotion';
|
const id = 'test-stage-promotion';
|
||||||
const db = () => getFirestore();
|
const db = () => getFirestore();
|
||||||
const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage');
|
const doc = async () => (await db().doc(`fandoms/${id}`).get());
|
||||||
|
const stage = async () => (await doc()).get('stage');
|
||||||
const set = (data: Record<string, unknown>) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data });
|
const set = (data: Record<string, unknown>) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(() => { bootAdmin(); });
|
||||||
bootAdmin();
|
|
||||||
for (const [n, pop, sfpi] of STAGES) {
|
|
||||||
await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
push.mockClear();
|
push.mockClear();
|
||||||
await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||||
@@ -33,25 +30,59 @@ afterAll(async () => {
|
|||||||
await shutdownAdmin();
|
await shutdownAdmin();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('조건 미달 · 한쪽만 충족이면 대기한다', async () => {
|
it('성장 진입 누계는 수치표 그대로 110,500 POP · 16,575 sFPI', () => {
|
||||||
for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) {
|
expect([LV21_POP, LV21_SFPI]).toEqual([110_500, 16_575]);
|
||||||
|
expect(cumulativeFor(20)).toEqual([100_000, 15_000]);
|
||||||
|
expect(cumulativeFor(100)).toEqual([50_000_000, 7_500_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('조건 미달 · 한쪽만 충족이면 대기한다 — Level 은 둘 다 도달한 만큼', async () => {
|
||||||
|
for (const [pop, sfpi] of [[LV21_POP - 1, LV21_SFPI], [5_000_000, LV21_SFPI - 1], [LV21_POP - 1, 9_000_000]]) {
|
||||||
await set({ stage: 1, growthPop: pop, sfpi });
|
await set({ stage: 1, growthPop: pop, sfpi });
|
||||||
expect(await promoteStages([id])).toEqual([]);
|
expect(await promoteStages([id])).toEqual([]);
|
||||||
expect(await stage()).toBe(1);
|
expect(await stage()).toBe(1);
|
||||||
|
expect((await doc()).get('level')).toBe(20);
|
||||||
}
|
}
|
||||||
expect(push).not.toHaveBeenCalled();
|
expect(push).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => {
|
it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => {
|
||||||
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
|
await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI });
|
||||||
await Promise.all([promoteStages([id]), promoteStages([id])]);
|
await Promise.all([promoteStages([id]), promoteStages([id])]);
|
||||||
await promoteStages([id]);
|
await promoteStages([id]);
|
||||||
expect(await stage()).toBe(2);
|
expect(await stage()).toBe(2);
|
||||||
|
const d = await doc();
|
||||||
|
expect(d.get('level')).toBe(21);
|
||||||
|
// 증가분은 한 번만 진행도에 들어간다.
|
||||||
|
expect(d.get('levelPop')).toBe(LV21_POP);
|
||||||
|
expect(d.get('nextLevelPop')).toEqual(cumulativeFor(22)[0]);
|
||||||
|
expect(d.get('nextStagePop')).toEqual(cumulativeFor(41)[0]);
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('생성 기준점 이후 증가분만 진행도에 쓴다(수치표 「레벨 진행 기준점」)', async () => {
|
||||||
|
await set({ stage: 1, growthPop: 50_000 + LV21_POP - 1, growthPopAtBirth: 50_000, sfpi: LV21_SFPI });
|
||||||
|
await promoteStages([id]);
|
||||||
|
expect(await stage()).toBe(1);
|
||||||
|
expect((await doc()).get('levelPop')).toBe(LV21_POP - 1);
|
||||||
|
// 성운 기여 POP 은 누계에 그대로 남는다(「소멸·초기화 없음」).
|
||||||
|
expect((await doc()).get('growthPop')).toBe(50_000 + LV21_POP - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZAP 참여도 가중치는 레벨업 POP 진행도에만 곱하고 원장 누계는 그대로 둔다', async () => {
|
||||||
|
const raw = Math.ceil(LV21_POP / 1.5);
|
||||||
|
await set({ stage: 1, growthPop: raw, sfpi: LV21_SFPI, zapWeight: 1.5 });
|
||||||
|
await promoteStages([id]);
|
||||||
|
expect(await stage()).toBe(2);
|
||||||
|
expect((await doc()).get('growthPop')).toBe(raw);
|
||||||
|
// 상한 ×1.5 를 넘는 값은 상한으로 본다.
|
||||||
|
await set({ stage: 1, growthPop: raw - 10, sfpi: LV21_SFPI, zapWeight: 9 });
|
||||||
|
await promoteStages([id]);
|
||||||
|
expect(await stage()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => {
|
it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => {
|
||||||
// POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3.
|
// POP 은 Stage5 까지, sFPI 는 Lv.41~60(Stage3) 까지 → Stage3.
|
||||||
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
|
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
|
||||||
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
|
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
|
||||||
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
|
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
|
||||||
@@ -62,16 +93,37 @@ it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 ·
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => {
|
it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => {
|
||||||
await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 });
|
await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI - 1_000, fpiScore: 1_000 });
|
||||||
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
||||||
expect(await stage()).toBe(2);
|
expect(await stage()).toBe(2);
|
||||||
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
|
||||||
expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000);
|
expect((await doc()).get('sfpi')).toBe(LV21_SFPI);
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('일일 마감이 ZAP 참여율(ZAP 고유 사용자 ÷ 최근 30일 Active)로 성장 가중치를 정한다', async () => {
|
||||||
|
await set({ stage: 1, growthPop: 0, sfpi: 0, fpiScore: 0 });
|
||||||
|
const at = (iso: string) => Timestamp.fromDate(new Date(iso));
|
||||||
|
const ev = (key: string, uid: string, activity: string, iso: string) =>
|
||||||
|
db().doc(`fandoms/${id}/fpiEvents/${key}`).set({ uid, activity, verified: true, occurredAt: at(iso) });
|
||||||
|
// 30일 Active 4명, 마감일(2030-03-01) ZAP 2명(한 사람 2회) → 50% → ×1.50.
|
||||||
|
await ev('a', 'u1', 'zap', '2030-03-01T10:00:00+09:00');
|
||||||
|
await ev('b', 'u1', 'zap', '2030-03-01T11:00:00+09:00');
|
||||||
|
await ev('c', 'u2', 'zap', '2030-03-01T12:00:00+09:00');
|
||||||
|
await ev('d', 'u3', 'steps', '2030-02-20T12:00:00+09:00');
|
||||||
|
await ev('e', 'u4', 'attend', '2030-02-10T12:00:00+09:00');
|
||||||
|
await ev('old', 'u5', 'attend', '2029-12-01T12:00:00+09:00');
|
||||||
|
await closeDay(new Date('2030-03-02T00:00:30+09:00'));
|
||||||
|
const stats = await db().doc(`fandoms/${id}/fandomDailyStats/2030-03-01`).get();
|
||||||
|
expect([stats.get('zapUsers'), stats.get('activeUsers'), stats.get('zapRate')]).toEqual([2, 4, 0.5]);
|
||||||
|
expect((await doc()).get('zapWeight')).toBe(1.5);
|
||||||
|
// 다음 날 ZAP 0 → 2일 평균 25% → ×1.20.
|
||||||
|
await closeDay(new Date('2030-03-03T00:00:30+09:00'));
|
||||||
|
expect((await doc()).get('zapWeight')).toBe(1.2);
|
||||||
|
});
|
||||||
|
|
||||||
it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => {
|
it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => {
|
||||||
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
|
await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI });
|
||||||
const run = (role: string) => fnAdminPlanetEvolutionRun.run({
|
const run = (role: string) => fnAdminPlanetEvolutionRun.run({
|
||||||
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
|
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
|
||||||
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
|
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
|
||||||
|
|||||||
@@ -22,8 +22,41 @@ const planetColors = <(String, Color)>[
|
|||||||
('purple', Color(0xFFC300C8)),
|
('purple', Color(0xFFC300C8)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// 컬러 마스터 `config/planetColors` 에서 읽은 **전체** 목록(숨긴 색 포함 — 이미 그 색인 행성을 그려야 한다).
|
||||||
|
/// 10/3 통합회신 #6 「발주사가 컬러 항목을 추가할 수 있는 구조 · 8색 고정 적용 안 함」. 읽기 전에는 [planetColors].
|
||||||
|
List<(String, Color)> _planetColorMaster = planetColors;
|
||||||
|
|
||||||
|
/// `config/planetColors` 문서 → (그리기용 전체, 생성 화면에 내놓을 노출 목록). 형식이 틀린 항목은 건너뛴다.
|
||||||
|
/// 읽을 수 있는 항목이 없으면 기본 목록([planetColors]).
|
||||||
|
({List<(String, Color)> all, List<(String, Color)> visible}) planetColorsFrom(
|
||||||
|
Map<String, dynamic>? doc,
|
||||||
|
) {
|
||||||
|
final all = <(String, Color)>[];
|
||||||
|
final visible = <(String, Color)>[];
|
||||||
|
final raw = doc?['colors'];
|
||||||
|
if (raw is List) {
|
||||||
|
for (final c in raw) {
|
||||||
|
if (c is! Map) continue;
|
||||||
|
final key = c['key'];
|
||||||
|
final hex = c['hex'];
|
||||||
|
final m = hex is String ? RegExp(r'^#?([0-9a-fA-F]{6})$').firstMatch(hex) : null;
|
||||||
|
if (key is! String || key.isEmpty || m == null) continue;
|
||||||
|
final entry = (key, Color(0xFF000000 | int.parse(m.group(1)!, radix: 16)));
|
||||||
|
all.add(entry);
|
||||||
|
if (c['visible'] != false) visible.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible.isEmpty
|
||||||
|
? (all: [...all, ...planetColors], visible: planetColors)
|
||||||
|
: (all: all, visible: visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마스터를 읽은 뒤 그리기 목록을 바꾼다.
|
||||||
|
void setPlanetColorMaster(List<(String, Color)> all) => _planetColorMaster = all;
|
||||||
|
|
||||||
/// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로.
|
/// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로.
|
||||||
Color? planetColorOf(String? key) =>
|
Color? planetColorOf(String? key) =>
|
||||||
|
_planetColorMaster.where((c) => c.$1 == key).firstOrNull?.$2 ??
|
||||||
planetColors.where((c) => c.$1 == key).firstOrNull?.$2;
|
planetColors.where((c) => c.$1 == key).firstOrNull?.$2;
|
||||||
|
|
||||||
/// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다.
|
/// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
/// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다.
|
/// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/painting.dart' show Color;
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
import '../../core/ui/planet_25d.dart' show planetColors;
|
||||||
import '../quest/quest_data.dart' show QuestType;
|
import '../quest/quest_data.dart' show QuestType;
|
||||||
import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository;
|
import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository;
|
||||||
|
|
||||||
@@ -414,6 +416,9 @@ abstract interface class HomeRepository {
|
|||||||
/// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy`
|
/// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy`
|
||||||
/// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets].
|
/// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets].
|
||||||
Future<NebulaTargets> nebulaTargets();
|
Future<NebulaTargets> nebulaTargets();
|
||||||
|
|
||||||
|
/// 행성 생성 화면이 내놓을 컬러 — 컬러 마스터 `config/planetColors`(10/3 #6 · C-115). 읽지 못하면 Figma 기본 8색.
|
||||||
|
Future<List<(String, Color)>> planetColorOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일).
|
/// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일).
|
||||||
@@ -451,6 +456,9 @@ class SampleHomeRepository implements HomeRepository {
|
|||||||
@override
|
@override
|
||||||
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
|
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<(String, Color)>> planetColorOptions() async => planetColors;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp;
|
|||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/data/invite_code.dart';
|
import '../../core/data/invite_code.dart';
|
||||||
import '../../core/data/server_clock.dart';
|
import '../../core/data/server_clock.dart';
|
||||||
|
import '../../core/ui/planet_25d.dart' show planetColors, planetColorsFrom, setPlanetColorMaster;
|
||||||
|
import 'package:flutter/painting.dart' show Color;
|
||||||
import 'home_data.dart';
|
import 'home_data.dart';
|
||||||
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
||||||
import '../quest/quest_data.dart' show QuestType;
|
import '../quest/quest_data.dart' show QuestType;
|
||||||
@@ -66,6 +68,19 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
'colorKey': colorKey,
|
'colorKey': colorKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<(String, Color)>> planetColorOptions() async {
|
||||||
|
try {
|
||||||
|
final master = planetColorsFrom(
|
||||||
|
(await _backend.db.doc('config/planetColors').get()).data(),
|
||||||
|
);
|
||||||
|
setPlanetColorMaster(master.all);
|
||||||
|
return master.visible;
|
||||||
|
} catch (_) {
|
||||||
|
return planetColors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<NebulaTargets> nebulaTargets() async {
|
Future<NebulaTargets> nebulaTargets() async {
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +97,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
final uid = _backend.requireUid;
|
final uid = _backend.requireUid;
|
||||||
// 공유 링크 `?ref=` 용 — 기다리지 않는다.
|
// 공유 링크 `?ref=` 용 — 기다리지 않는다.
|
||||||
InviteCode.ensure(_backend);
|
InviteCode.ensure(_backend);
|
||||||
|
// 컬러 마스터 — HOME · GALAXY 행성이 발주사가 더한 색도 그리도록 먼저 읽어 둔다. 기다리지 않는다.
|
||||||
|
planetColorOptions();
|
||||||
final db = _backend.db;
|
final db = _backend.db;
|
||||||
|
|
||||||
final userSnap = await db.doc('users/$uid').get();
|
final userSnap = await db.doc('users/$uid').get();
|
||||||
@@ -291,8 +308,15 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
int? validInt(dynamic value) => value is int && value >= 0 ? value : null;
|
int? validInt(dynamic value) => value is int && value >= 0 ? value : null;
|
||||||
num? validNumber(dynamic value) =>
|
num? validNumber(dynamic value) =>
|
||||||
value is num && value.isFinite && value >= 0 ? value : null;
|
value is num && value.isFinite && value >= 0 ? value : null;
|
||||||
final current = validNumber(fandom['growthPop']);
|
// 정식 행성은 서버가 수치표 v1.4 대로 적은 레벨업 POP 진행도(생성 기준점 이후 · ZAP 가중치 반영)와
|
||||||
final required = validInt(next?['requiredPop']);
|
// 다음 Stage 진입 누계를 쓴다. 아직 판정 전인 행성만 구 마스터(`planetStages`)로 그린다.
|
||||||
|
final leveled = stage != null && stage > 0 && fandom['levelPop'] is num;
|
||||||
|
final current = leveled
|
||||||
|
? validNumber(fandom['levelPop'])
|
||||||
|
: validNumber(fandom['growthPop']);
|
||||||
|
final required = leveled
|
||||||
|
? validInt(fandom['nextStagePop'])
|
||||||
|
: validInt(next?['requiredPop']);
|
||||||
// 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다.
|
// 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다.
|
||||||
final policy = stage == 0
|
final policy = stage == 0
|
||||||
? (await _backend.db.doc('config/policy').get()).data()
|
? (await _backend.db.doc('config/policy').get()).data()
|
||||||
@@ -325,7 +349,9 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
// Q-PN-01 A안 — 탄생은 창립자만.
|
// Q-PN-01 A안 — 탄생은 창립자만.
|
||||||
isFounder:
|
isFounder:
|
||||||
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
||||||
requiredSfpi: validNumber(next?['requiredSfpi']),
|
requiredSfpi: leveled
|
||||||
|
? validNumber(fandom['nextStageSfpi'])
|
||||||
|
: validNumber(next?['requiredSfpi']),
|
||||||
nebulaGoalPassed: goalPassed,
|
nebulaGoalPassed: goalPassed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class PlanetCreateScreen extends StatefulWidget {
|
|||||||
'shape6',
|
'shape6',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 색 키와 칩 색 — 렌더링과 같은 표([planetColors]).
|
/// 기본 색 키와 칩 색(Figma `2066:17910`). 화면은 컬러 마스터(`config/planetColors`, 10/3 #6)를
|
||||||
|
/// [HomeRepository.planetColorOptions] 로 읽어 덮는다 — 발주사가 더한 색도 고를 수 있다.
|
||||||
static const colors = planetColors;
|
static const colors = planetColors;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -53,6 +54,19 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
int _shape = 0;
|
int _shape = 0;
|
||||||
int _color = 0;
|
int _color = 0;
|
||||||
bool _colorStep = false;
|
bool _colorStep = false;
|
||||||
|
List<(String, Color)> _colors = PlanetCreateScreen.colors;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.repository.planetColorOptions().then((options) {
|
||||||
|
if (!mounted || options.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_colors = options;
|
||||||
|
_color = _color.clamp(0, options.length - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static const _placeholder = AssetImage(
|
static const _placeholder = AssetImage(
|
||||||
'assets/figma/planet-shape-placeholder.png',
|
'assets/figma/planet-shape-placeholder.png',
|
||||||
@@ -72,7 +86,7 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
child: _colorStep
|
child: _colorStep
|
||||||
? ColorFiltered(
|
? ColorFiltered(
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(
|
||||||
PlanetCreateScreen.colors[_color].$2,
|
_colors[_color].$2,
|
||||||
BlendMode.modulate,
|
BlendMode.modulate,
|
||||||
),
|
),
|
||||||
child: const Image(image: _placeholder),
|
child: const Image(image: _placeholder),
|
||||||
@@ -141,7 +155,7 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2.
|
/// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2.
|
||||||
Widget _colors() => Column(
|
Widget _colorPicker() => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(context.l10n.planetBgColor, style: _heading),
|
Text(context.l10n.planetBgColor, style: _heading),
|
||||||
@@ -150,19 +164,19 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < PlanetCreateScreen.colors.length; i++)
|
for (var i = 0; i < _colors.length; i++)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _color = i),
|
onTap: () => setState(() => _color = i),
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: _color == i,
|
selected: _color == i,
|
||||||
label: PlanetCreateScreen.colors[i].$1,
|
label: _colors[i].$1,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: PlanetCreateScreen.colors[i].$2,
|
color: _colors[i].$2,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _color == i
|
color: _color == i
|
||||||
? AppColors.cosmicAccent
|
? AppColors.cosmicAccent
|
||||||
@@ -200,7 +214,7 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_preview(),
|
_preview(),
|
||||||
const SizedBox(height: AppSpace.s5),
|
const SizedBox(height: AppSpace.s5),
|
||||||
if (_colorStep) _colors() else _shapes(),
|
if (_colorStep) _colorPicker() else _shapes(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -257,7 +271,7 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|||||||
await widget.repository.createPlanet(
|
await widget.repository.createPlanet(
|
||||||
fandomId: widget.fandomId,
|
fandomId: widget.fandomId,
|
||||||
planetType: PlanetCreateScreen.shapes[_shape],
|
planetType: PlanetCreateScreen.shapes[_shape],
|
||||||
colorKey: PlanetCreateScreen.colors[_color].$1,
|
colorKey: _colors[_color].$1,
|
||||||
);
|
);
|
||||||
if (mounted) Navigator.of(context).pop(widget.fandomId);
|
if (mounted) Navigator.of(context).pop(widget.fandomId);
|
||||||
} on BackendException catch (e) {
|
} on BackendException catch (e) {
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ void main() {
|
|||||||
expect(values.current.popConditionLabel, 'POP 조건 미충족');
|
expect(values.current.popConditionLabel, 'POP 조건 미충족');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('서버가 Level 판정을 적은 행성은 레벨업 POP 진행도와 다음 Stage 진입 누계로 그린다 (수치표 v1.4)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
// 원장 누계 growthPop 이 아니라 기준점 이후 · 가중치 반영 진행도(levelPop)를 쓴다.
|
||||||
|
await db.doc('fandoms/bts').update({
|
||||||
|
'level': 20, 'levelPop': 105000.5, 'nextStagePop': 110500, 'nextStageSfpi': 16575,
|
||||||
|
});
|
||||||
|
final home = await FirestoreHomeRepository(_backend(db)).load();
|
||||||
|
expect(home.growth.currentPop, 105000.5);
|
||||||
|
expect(home.growth.requiredPop, 110500);
|
||||||
|
expect(home.growth.requiredSfpi, 16575);
|
||||||
|
});
|
||||||
|
|
||||||
test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async {
|
test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'});
|
await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'});
|
||||||
@@ -679,9 +691,15 @@ void main() {
|
|||||||
await db.doc('users/$_uid').set({
|
await db.doc('users/$_uid').set({
|
||||||
'uid': _uid, 'fandomId': 'bts', 'nickname': '아미보이',
|
'uid': _uid, 'fandomId': 'bts', 'nickname': '아미보이',
|
||||||
'totalContributedPop': 24800, 'userExp': 500000, 'userLevel': 31,
|
'totalContributedPop': 24800, 'userExp': 500000, 'userLevel': 31,
|
||||||
'passportTier': 'GOLD',
|
// 사용자 문서의 단일 Tier 는 읽지 않는다 — Passport 는 팬덤별 문서다(10/3 #10).
|
||||||
|
'passportTier': 'DIAMOND',
|
||||||
});
|
});
|
||||||
await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800});
|
await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800});
|
||||||
|
await db.doc('users/$_uid/passports/bts').set({'tier': 'GOLD'});
|
||||||
|
// 탈퇴한 팬덤의 Passport 도 보존 기록으로 보인다(9/21 5절).
|
||||||
|
await db.doc('fandoms/left').set({'name': '떠난팬덤'});
|
||||||
|
await db.doc('users/$_uid/passports/left').set({'tier': 'DIAMOND', 'contributedPop': 700});
|
||||||
|
await db.doc('users/$_uid/badges/activity_health').set({'kind': 'activity', 'axis': 'health', 'stage': 2});
|
||||||
|
|
||||||
final profile = await FirestoreMyRepository(_backend(db)).profile();
|
final profile = await FirestoreMyRepository(_backend(db)).profile();
|
||||||
expect(profile.contributedPop, 24800);
|
expect(profile.contributedPop, 24800);
|
||||||
@@ -689,7 +707,13 @@ void main() {
|
|||||||
expect(profile.levelLabel, 'Lv.31 BEAM');
|
expect(profile.levelLabel, 'Lv.31 BEAM');
|
||||||
expect(profile.userExp, 500000);
|
expect(profile.userExp, 500000);
|
||||||
expect(profile.passportTier, PassportTier.gold);
|
expect(profile.passportTier, PassportTier.gold);
|
||||||
expect(profile.planetTiers.single.contributedPop, 24800);
|
final bts = profile.planetTiers.singleWhere((t) => t.fandomId == 'bts');
|
||||||
|
expect(bts.contributedPop, 24800);
|
||||||
|
expect(bts.isPrimary, isTrue);
|
||||||
|
final left = profile.planetTiers.singleWhere((t) => t.fandomId == 'left');
|
||||||
|
expect([left.joined, left.passportTier, left.contributedPop, left.fandomName],
|
||||||
|
[false, PassportTier.diamond, 700, '떠난팬덤']);
|
||||||
|
expect(profile.badges.single.label, 'HEALTH · 2/7');
|
||||||
expect(profile.balancePop, 12540);
|
expect(profile.balancePop, 12540);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test('컬러는 마스터(config/planetColors)가 정한다 — 추가 색은 그리고 숨긴 색은 고르지 못한다 (10/3 #6)', () {
|
||||||
|
final master = planetColorsFrom({'colors': [
|
||||||
|
{'key': 'gold', 'hex': '#FFC500', 'visible': false},
|
||||||
|
{'key': 'mint', 'hex': '3EB489'},
|
||||||
|
{'key': 'bad', 'hex': 'not-a-color'},
|
||||||
|
]});
|
||||||
|
expect(master.visible.map((c) => c.$1), ['mint']);
|
||||||
|
expect(master.all.map((c) => c.$1), ['gold', 'mint']);
|
||||||
|
setPlanetColorMaster(master.all);
|
||||||
|
addTearDown(() => setPlanetColorMaster(planetColors));
|
||||||
|
expect(planetColorOf('mint'), const Color(0xFF3EB489));
|
||||||
|
expect(planetColorOf('gold'), const Color(0xFFFFC500));
|
||||||
|
// 마스터가 비면 Figma 기본 8색.
|
||||||
|
expect(planetColorsFrom(null).visible, planetColors);
|
||||||
|
});
|
||||||
|
|
||||||
// C-50 — 생성 화면이 고른 colorKey 를 같은 팔레트로 본체에 입힌다.
|
// C-50 — 생성 화면이 고른 colorKey 를 같은 팔레트로 본체에 입힌다.
|
||||||
testWidgets('행성 본체는 서버 colorKey 의 생성 팔레트 색을 입는다', (tester) async {
|
testWidgets('행성 본체는 서버 colorKey 의 생성 팔레트 색을 입는다', (tester) async {
|
||||||
expect(identical(PlanetCreateScreen.colors, planetColors), isTrue);
|
expect(identical(PlanetCreateScreen.colors, planetColors), isTrue);
|
||||||
|
|||||||
Reference in New Issue
Block a user