행성 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). */
|
||||
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'];
|
||||
|
||||
/** 생성 화면이 고를 수 있는(노출 중인) 컬러 키 — 마스터의 `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` 가 덮는다.
|
||||
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
||||
@@ -344,7 +359,7 @@ export const fnPlanetCreate = onCall(async (req) => {
|
||||
const fandomId = str(req.data, 'fandomId');
|
||||
const planetType = str(req.data, 'planetType');
|
||||
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', '행성 형태 또는 색이 올바르지 않습니다.');
|
||||
}
|
||||
const db = getFirestore();
|
||||
@@ -375,10 +390,18 @@ export const fnPlanetCreate = onCall(async (req) => {
|
||||
colorKey,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
// 수치표 v1.4 「레벨 진행 기준점」 — 생성 시점 누적 POP 을 저장하고 이후 증가분부터 Lv.1→2 진행도에
|
||||
// 반영한다. 성운 기여 POP 은 growthPop 에 그대로 남는다(「소멸·초기화 없음」).
|
||||
const birthPop = Number(fandom.growthPop ?? 0);
|
||||
tx.update(fandomRef, {
|
||||
stage: 1,
|
||||
level: 1,
|
||||
levelPop: 0,
|
||||
growthPopAtBirth: birthPop,
|
||||
levelCountedGrowthPop: birthPop,
|
||||
planetType,
|
||||
colorKey,
|
||||
stageChangedAt: 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 { sendFandomPush } from '../notify/push.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장). */
|
||||
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
@@ -101,29 +102,21 @@ const FINAL_STAGE = 6;
|
||||
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 진입값
|
||||
* (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다.
|
||||
* 하나만 채우면 그대로 대기한다.
|
||||
* - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서
|
||||
* 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다.
|
||||
* - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다.
|
||||
* - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어
|
||||
* 승급시키지 않는다.
|
||||
* - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`,
|
||||
* 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다.
|
||||
* - Level = 진행도와 sFPI 가 **둘 다** 도달한 가장 높은 Level(동시 충족 · Carry-over). Stage = Level 구간.
|
||||
* - 강등하지 않는다(B-53 「Stage 강등 0건」) — Level 도 내리지 않는다. Stage6(Lv.100)이 마지막이다.
|
||||
* - 앱이 HOME · 상세에 그리도록 다음 Level · 다음 Stage 진입 누계를 함께 적는다.
|
||||
*
|
||||
* 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등).
|
||||
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다.
|
||||
* 증가분을 반영한 뒤 `levelCountedGrowthPop` 을 옮기므로 하루에 여러 번 돌아도 두 번 세지 않는다(멱등).
|
||||
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` · `level` 상승을 보고 띄운다.
|
||||
*/
|
||||
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
|
||||
const db = getFirestore();
|
||||
const stageDocs = await db.collection('planetStages').get();
|
||||
const required = new Map<number, { pop: number; sfpi: number }>();
|
||||
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 cfg = await loadGrowthConfig();
|
||||
const maxWeight = Math.max(1, ...cfg.zapWeightBands.map(([, w]) => w));
|
||||
|
||||
const refs = fandomIds
|
||||
? 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 from = snap.get('stage');
|
||||
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);
|
||||
let to = from as number;
|
||||
for (let next = to + 1; next <= FINAL_STAGE; next++) {
|
||||
const req = required.get(next);
|
||||
if (!req || pop < req.pop || sfpi < req.sfpi) break;
|
||||
to = next;
|
||||
}
|
||||
const prevLevel = Number.isInteger(snap.get('level')) ? snap.get('level') as number : 1;
|
||||
const level = Math.max(prevLevel, planetLevelOf(levelPop, sfpi, cfg.steps));
|
||||
const to = Math.min(FINAL_STAGE, Math.max(from as number, stageOfLevel(level, cfg.stageLevelFrom)));
|
||||
const [nextLevelPop, nextLevelSfpi] = cumulativeFor(level + 1, cfg.steps);
|
||||
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;
|
||||
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 };
|
||||
});
|
||||
if (!result) continue;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
import { promoteStages } from './evolution.js';
|
||||
import { loadGrowthConfig, zapWeightOf } from './planet_level.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 to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000);
|
||||
const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get();
|
||||
const growth = await loadGrowthConfig();
|
||||
|
||||
for (const fandom of fandoms.docs) {
|
||||
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}`)를 함께 써서 개수만 세도록 옮긴다.
|
||||
const events = await fandom.ref.collection('fpiEvents')
|
||||
.where('occurredAt', '>=', from).where('occurredAt', '<', to).get();
|
||||
const users = new Set(events.docs
|
||||
.filter((e) => e.get('verified') === true && typeof e.get('uid') === 'string'
|
||||
&& e.get('dauEligible') !== false)
|
||||
const active = (e: FirebaseFirestore.QueryDocumentSnapshot) => e.get('verified') === true &&
|
||||
typeof e.get('uid') === 'string' && 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));
|
||||
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) => {
|
||||
@@ -903,8 +918,14 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
const snapshot = await tx.get(snapshotRef);
|
||||
// DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다.
|
||||
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;
|
||||
tx.create(snapshotRef, {
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user