행성 Level 1~100 수치표 적용 · 행성 컬러 마스터
This commit is contained in:
@@ -274,4 +274,19 @@ describe('행성 탄생', () => {
|
||||
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
|
||||
.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 { getFirestore } from 'firebase-admin/firestore';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { cumulativeFor } from '../src/fandom/planet_level.js';
|
||||
|
||||
const push = vi.hoisted(() => vi.fn(async () => true));
|
||||
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 { closeDay } = await import('../src/fandom/fandom.js');
|
||||
|
||||
/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */
|
||||
const STAGES: Array<[number, number, number]> = [
|
||||
[1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000],
|
||||
[5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000],
|
||||
];
|
||||
/**
|
||||
* 수치표 v1.4 — Stage 는 Level 구간(시작 Lv.1~20 · 성장 21~). 성장(Stage2) 진입 = Lv.21 누계.
|
||||
* Level Detail Lv.1→20 합 100,000 / 15,000 + Lv.20→21 10,500 / 1,575.
|
||||
*/
|
||||
const [LV21_POP, LV21_SFPI] = cumulativeFor(21);
|
||||
const id = 'test-stage-promotion';
|
||||
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 });
|
||||
|
||||
beforeAll(async () => {
|
||||
bootAdmin();
|
||||
for (const [n, pop, sfpi] of STAGES) {
|
||||
await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi });
|
||||
}
|
||||
});
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
beforeEach(async () => {
|
||||
push.mockClear();
|
||||
await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||
@@ -33,25 +30,59 @@ afterAll(async () => {
|
||||
await shutdownAdmin();
|
||||
});
|
||||
|
||||
it('조건 미달 · 한쪽만 충족이면 대기한다', async () => {
|
||||
for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) {
|
||||
it('성장 진입 누계는 수치표 그대로 110,500 POP · 16,575 sFPI', () => {
|
||||
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 });
|
||||
expect(await promoteStages([id])).toEqual([]);
|
||||
expect(await stage()).toBe(1);
|
||||
expect((await doc()).get('level')).toBe(20);
|
||||
}
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 promoteStages([id]);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3.
|
||||
// POP 은 Stage5 까지, sFPI 는 Lv.41~60(Stage3) 까지 → Stage3.
|
||||
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
|
||||
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
|
||||
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
|
||||
@@ -62,16 +93,37 @@ it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 ·
|
||||
});
|
||||
|
||||
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'));
|
||||
expect(await stage()).toBe(2);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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({
|
||||
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
|
||||
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
|
||||
|
||||
Reference in New Issue
Block a user