+
0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
+ height: size,
+ left: -size / 2,
+ position: 'absolute',
+ top: -size / 2,
+ width: size,
+ }}
+ />
+ {glint > 1 ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+ })}
+
+ {skyFlood > 0 ? (
+
+ ) : null}
+
+ {meteorVisible
+ ? Array.from({length: fx.trailSamples}, (_, index) => {
+ const sampleProgress = meteorProgress - index * trailSpacing;
+ if (sampleProgress <= 0) {
+ return null;
+ }
+ const point = getMeteorPoint(sampleProgress, width, height);
+ const fade = 1 - index / fx.trailSamples;
+ const radius = headRadius * (0.25 + 0.75 * fade ** 1.3);
+ return (
+
+ );
+ })
+ : null}
+
+ {meteorVisible
+ ? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
+ if (spark.born > meteorProgress) {
+ return null;
+ }
+ const age = (meteorProgress - spark.born) * 4;
+ if (age >= 1) {
+ return null;
+ }
+ const origin = getMeteorPoint(spark.born, width, height);
+ const size = spark.size * height * (1 - age) * fx.headScale;
+ return (
+
+ );
+ })
+ : null}
+
+ {meteorVisible && haloOpacity > 0 ? (
+
+ {sunburstLayers.map((layer) => (
+
+ ))}
+
+ ) : null}
+
+ {meteorVisible ? (
+ <>
+
+ {props.rarity >= 4 ? (
+ <>
+
+
+ >
+ ) : null}
+ >
+ ) : null}
+
+ ) : null}
+
+ {revealed ? (
+
+
+ {fx.counterRays ? (
+
+ ) : null}
+
+ {fx.starburst
+ ? Array.from({length: starburstSpikes}, (_, index) => {
+ const long = index % 2 === 0;
+ const length = height * (long ? 1.1 : 0.7) * starburstScale;
+ return (
+
+ );
+ })
+ : null}
+
+ {revealMotes.slice(0, fx.motes).map((mote, index) => {
+ const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1;
+ const size = mote.size * height;
+ return (
+
+ );
+ })}
+
+ {rainSparkles.slice(0, fx.sparkleRain).map((sparkle, index) => {
+ const y = ((sparkle.offset + (seconds - tl.revealStart) * sparkle.speed) % 1.2) - 0.1;
+ const size = sparkle.size * height;
+ return (
+
+ );
+ })}
+
+ {Array.from({length: fx.shockwaves}, (_, index) => {
+ const start = tl.revealStart + index * 0.16;
+ const progress = interpolate(seconds, [start, start + 0.9], [0, 1], {
+ ...clamp,
+ easing: Easing.out(Easing.quad),
+ });
+ if (progress <= 0 || progress >= 1) {
+ return null;
+ }
+ return (
+
+ );
+ })}
+
+
+
+
+ {fx.sheen && seconds >= tl.emblemSettled ? (
+
+ ) : null}
+
+
+
+ {rankLetter}
+
+
+
+
+
+ {props.label}
+
+
+ {starTimes.map((time, index) => {
+ const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp);
+ const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp);
+ const flareSize = starSize * 2.4 * fx.starFlare;
+ return (
+
= time ? 1 : 0,
+ position: 'relative',
+ transform: `scale(${pop})`,
+ width: starSize,
+ }}
+ >
+
+
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ {flashOpacity > 0 ? (
+
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/renderer/src/remotion/Root.jsx b/renderer/src/remotion/Root.jsx
index e54c90f..f0ba263 100644
--- a/renderer/src/remotion/Root.jsx
+++ b/renderer/src/remotion/Root.jsx
@@ -1,4 +1,6 @@
import {Composition} from 'remotion';
+import {GenshinComposition} from './GenshinComposition.jsx';
+import {genshinFrameSizes, genshinTotalSeconds} from './genshin-timeline.js';
import {WheelComposition} from './WheelComposition.jsx';
/** @type {import('../schemas/wheel-request.js').WheelRenderRequest} */
@@ -29,15 +31,50 @@ export const getCompositionMetadata = (props) => {
};
};
+/** @type {import('../schemas/gacha-request.js').GachaRenderRequest} */
+const defaultGenshinProps = {
+ label: 'Pizza',
+ rarity: 5,
+ fps: 24,
+ width: 640,
+ seed: 7,
+};
+
+/**
+ * @param {{fps: number, width: number}} props
+ */
+export const getGenshinCompositionMetadata = (props) => {
+ const fps = props.fps || defaultGenshinProps.fps;
+ const width = props.width in genshinFrameSizes ? props.width : defaultGenshinProps.width;
+ return {
+ durationInFrames: Math.ceil(genshinTotalSeconds * fps),
+ fps,
+ height: genshinFrameSizes[/** @type {640 | 854} */ (width)],
+ width,
+ };
+};
+
export const RemotionRoot = () => (
-
getCompositionMetadata(props)}
- component={WheelComposition}
- defaultProps={defaultProps}
- durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
- fps={defaultProps.fps}
- height={defaultProps.size}
- id="WheelGif"
- width={defaultProps.size}
- />
+ <>
+ getCompositionMetadata(props)}
+ component={WheelComposition}
+ defaultProps={defaultProps}
+ durationInFrames={Math.ceil(((defaultProps.durationMs + defaultProps.holdMs) / 1000) * defaultProps.fps)}
+ fps={defaultProps.fps}
+ height={defaultProps.size}
+ id="WheelGif"
+ width={defaultProps.size}
+ />
+ getGenshinCompositionMetadata(props)}
+ component={GenshinComposition}
+ defaultProps={defaultGenshinProps}
+ durationInFrames={Math.ceil(genshinTotalSeconds * defaultGenshinProps.fps)}
+ fps={defaultGenshinProps.fps}
+ height={genshinFrameSizes[defaultGenshinProps.width]}
+ id="GenshinWish"
+ width={defaultGenshinProps.width}
+ />
+ >
);
diff --git a/renderer/src/remotion/genshin-timeline.js b/renderer/src/remotion/genshin-timeline.js
new file mode 100644
index 0000000..12514b3
--- /dev/null
+++ b/renderer/src/remotion/genshin-timeline.js
@@ -0,0 +1,274 @@
+/**
+ * Timing, palette, and geometry for the Genshin-style meteor wish served on
+ * /api/genshin. Kept free of React so the choreography can be unit tested.
+ */
+
+/** @typedef {3 | 4 | 5} GenshinRarity */
+
+/**
+ * @typedef {object} RarityPalette
+ * @property {string} core Meteor head and hottest highlights.
+ * @property {string} glow Trail, ring burst, and emblem glow.
+ * @property {string} deep Background tint of the reveal scene.
+ * @property {string} emblem Emblem fill gradient, light to dark.
+ */
+
+export const genshinTotalSeconds = 7;
+
+/** Landscape frame heights for each requested width: 16:9, rounded to even pixels for H.264. */
+export const genshinFrameSizes = Object.freeze({640: 360, 854: 480});
+
+/**
+ * Seconds from the start of the clip. The meteor lands at `meteorEnd`, the
+ * white flash peaks and holds, then fades to reveal the result.
+ */
+export const genshinTimeline = Object.freeze({
+ meteorStart: 0.3,
+ meteorEnd: 2.5,
+ flashStart: 2.3,
+ flashPeak: 2.7,
+ flashHoldEnd: 2.95,
+ flashEnd: 3.5,
+ revealStart: 2.95,
+ emblemSettled: 3.7,
+ nameIn: 3.6,
+ nameSettled: 4.1,
+ starsStart: 4.1,
+ starStep: 0.18,
+});
+
+/** Centre of the rank emblem on the reveal, as fractions of the frame. */
+export const emblemCenter = Object.freeze({x: 0.66, y: 0.47});
+
+/** Every rarity reveals its stars in the same gold, as in the source game. */
+export const starColor = '#ffcc33';
+
+/** @type {Record} */
+const palettes = {
+ 3: {core: '#f2f8ff', glow: '#6fb6ff', deep: '#163a8a', emblem: '#9fd0ff, #3a6fd8'},
+ 4: {core: '#fbf2ff', glow: '#c88cff', deep: '#4a1d8f', emblem: '#e2c2ff, #8a4ae6'},
+ 5: {core: '#fffbe8', glow: '#ffd36b', deep: '#7a4a10', emblem: '#fff0b8, #e2a12e'},
+};
+
+/**
+ * @param {GenshinRarity} rarity
+ * @returns {RarityPalette}
+ */
+export const getRarityPalette = (rarity) => palettes[rarity];
+
+/**
+ * Point on the meteor's curved flight from the upper left toward the rank
+ * emblem on the right, as a quadratic Bézier. It lands exactly on the emblem,
+ * so the impact burst becomes the badge reveal.
+ *
+ * @param {number} progress 0 at launch, 1 at landing; values outside clamp.
+ * @param {number} width
+ * @param {number} height
+ * @returns {{x: number, y: number}}
+ */
+export const getMeteorPoint = (progress, width, height) => {
+ const t = Math.min(1, Math.max(0, progress));
+ const start = {x: -width * 0.08, y: -height * 0.18};
+ const control = {x: width * 0.22, y: height * 0.38};
+ const end = {x: width * emblemCenter.x, y: height * emblemCenter.y};
+ const u = 1 - t;
+ return {
+ x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
+ y: u * u * start.y + 2 * u * t * control.y + t * t * end.y,
+ };
+};
+
+/**
+ * Seconds at which each reveal star pops in, one per rarity level.
+ *
+ * @param {GenshinRarity} rarity
+ * @returns {number[]}
+ */
+export const getStarRevealTimes = (rarity) =>
+ Array.from({length: rarity}, (_, index) => genshinTimeline.starsStart + index * genshinTimeline.starStep);
+
+/**
+ * Deterministic pseudo-random generator (mulberry32) so every render of the
+ * same request draws the same sky.
+ *
+ * @param {number} seed
+ * @returns {() => number} values in [0, 1)
+ */
+export const createRandom = (seed) => {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let value = state;
+ value = Math.imul(value ^ (value >>> 15), value | 1);
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+ };
+};
+
+/**
+ * @typedef {object} SkyStar
+ * @property {number} x Fraction of width.
+ * @property {number} y Fraction of height.
+ * @property {number} radius Fraction of height.
+ * @property {number} phase Twinkle phase offset in radians.
+ * @property {number} speed Twinkle speed in radians per second.
+ * @property {boolean} glint Draws a cross-shaped glint when it flares.
+ */
+
+/**
+ * @param {number} count
+ * @param {number} seed
+ * @returns {SkyStar[]}
+ */
+export const createStarfield = (count, seed) => {
+ const random = createRandom(seed);
+ return Array.from({length: count}, () => ({
+ x: random(),
+ y: random() * 0.8,
+ radius: 0.002 + random() * 0.004,
+ phase: random() * Math.PI * 2,
+ speed: 2 + random() * 5,
+ glint: random() < 0.18,
+ }));
+};
+
+/**
+ * Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the
+ * time with short bright flares, which reads as twinkling rather than a slow
+ * pulse.
+ *
+ * @param {number} seconds
+ * @param {SkyStar} star
+ * @returns {number}
+ */
+export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3;
+
+/**
+ * Font size for the revealed name, shrinking long labels so they fit within
+ * two lines on the left half of the frame.
+ *
+ * @param {string} label
+ * @param {number} height
+ * @returns {number}
+ */
+export const getLabelFontSize = (label, height) => {
+ const length = Array.from(label).length;
+ if (length <= 8) {
+ return Math.round(height * 0.12);
+ }
+ if (length <= 16) {
+ return Math.round(height * 0.09);
+ }
+ if (length <= 28) {
+ return Math.round(height * 0.07);
+ }
+ return Math.round(height * 0.055);
+};
+
+/** Rank letter shown on the emblem for each rarity. */
+const rankLetters = {3: 'B', 4: 'A', 5: 'S'};
+
+/**
+ * @param {GenshinRarity} rarity
+ * @returns {'B' | 'A' | 'S'}
+ */
+export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLetters[rarity]);
+
+/**
+ * @typedef {object} TierEffects
+ * @property {number} headScale Meteor head size multiplier.
+ * @property {number} trailSamples Glow blobs drawn along the meteor trail.
+ * @property {number} sparks Sparks shed by the meteor.
+ * @property {boolean} halo Rainbow sunburst bursting from the meteor before landing.
+ * @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
+ * @property {number} shake Impact camera shake amplitude, as a fraction of height.
+ * @property {number} shockwaves Rings bursting from the emblem on reveal.
+ * @property {boolean} starburst Long light spikes behind the emblem.
+ * @property {number} raysOpacity Peak opacity of the rotating light rays.
+ * @property {boolean} counterRays Second ray layer rotating the other way.
+ * @property {number} motes Glowing motes floating up behind the reveal.
+ * @property {number} sparkleRain Sparkles falling across the reveal.
+ * @property {boolean} sheen Light sweep across the emblem and rank letter.
+ * @property {number} starFlare Size multiplier of each star's pop flare.
+ */
+
+/**
+ * How loud each tier is. Every beat exists at every tier; higher tiers get
+ * more of it, so 4★ clearly beats 3★ and 5★ clearly beats both.
+ *
+ * @type {Record}
+ */
+const tierEffects = {
+ 3: {
+ headScale: 1,
+ trailSamples: 22,
+ sparks: 12,
+ halo: false,
+ skyFlood: 0,
+ shake: 0,
+ shockwaves: 1,
+ starburst: false,
+ raysOpacity: 0.16,
+ counterRays: false,
+ motes: 14,
+ sparkleRain: 0,
+ sheen: false,
+ starFlare: 1,
+ },
+ 4: {
+ headScale: 1.3,
+ trailSamples: 34,
+ sparks: 36,
+ halo: false,
+ skyFlood: 0.25,
+ shake: 0.012,
+ shockwaves: 2,
+ starburst: false,
+ raysOpacity: 0.42,
+ counterRays: false,
+ motes: 32,
+ sparkleRain: 0,
+ sheen: false,
+ starFlare: 1.4,
+ },
+ 5: {
+ headScale: 1.75,
+ trailSamples: 48,
+ sparks: 80,
+ halo: true,
+ skyFlood: 0.7,
+ shake: 0.03,
+ shockwaves: 3,
+ starburst: true,
+ raysOpacity: 0.7,
+ counterRays: true,
+ motes: 56,
+ sparkleRain: 40,
+ sheen: true,
+ starFlare: 2,
+ },
+};
+
+/**
+ * @param {GenshinRarity} rarity
+ * @returns {TierEffects}
+ */
+export const getTierEffects = (rarity) => tierEffects[rarity];
+
+/**
+ * Decaying impact shake offset in pixels, zero before `start` and after it
+ * settles.
+ *
+ * @param {number} seconds
+ * @param {number} start
+ * @param {number} amplitude Pixels at the moment of impact.
+ * @returns {{x: number, y: number}}
+ */
+export const getShakeOffset = (seconds, start, amplitude) => {
+ const elapsed = seconds - start;
+ if (amplitude <= 0 || elapsed < 0 || elapsed > 0.8) {
+ return {x: 0, y: 0};
+ }
+ const decay = amplitude * (1 - elapsed / 0.8) ** 2;
+ return {x: Math.sin(elapsed * 71) * decay, y: Math.cos(elapsed * 53) * decay};
+};
diff --git a/renderer/src/render/render-genshin.js b/renderer/src/render/render-genshin.js
new file mode 100644
index 0000000..cd4af1b
--- /dev/null
+++ b/renderer/src/render/render-genshin.js
@@ -0,0 +1,24 @@
+import {renderComposition} from './render-composition.js';
+
+/**
+ * @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
+ * @typedef {import('./render-composition.js').RenderResult} RenderResult
+ */
+
+/**
+ * Renders the Genshin-style meteor wish as a silent H.264 MP4, which Telegram
+ * plays as a looping animation and which keeps the glow gradients free of GIF
+ * banding.
+ *
+ * @param {GachaRenderRequest} inputProps
+ * @param {{timeoutInMilliseconds: number}} options
+ * @returns {Promise}
+ */
+export const renderGenshinVideo = (inputProps, options) =>
+ renderComposition({
+ compositionId: 'GenshinWish',
+ filename: 'genshin.mp4',
+ inputProps: {...inputProps},
+ media: {codec: 'h264', crf: 23, imageFormat: 'jpeg', jpegQuality: 90, muted: true},
+ timeoutInMilliseconds: options.timeoutInMilliseconds,
+ });
diff --git a/renderer/src/routes/gacha.js b/renderer/src/routes/gacha.js
index 30df889..79b8f5e 100644
--- a/renderer/src/routes/gacha.js
+++ b/renderer/src/routes/gacha.js
@@ -14,6 +14,7 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @property {AppConfig} config
* @property {{tryAcquire: () => boolean, release: () => void}} semaphore
* @property {(request: GachaRenderRequest, options: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} renderGacha
+ * @property {string} [name] Wish style: serves /api/ and names the file .mp4. Defaults to gacha.
*/
/**
@@ -24,7 +25,8 @@ import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
* @param {GachaRouteDeps} deps
*/
export const registerGachaRoute = async (app, deps) => {
- app.post('/api/gacha', async (request, reply) => {
+ const name = deps.name ?? 'gacha';
+ app.post(`/api/${name}`, async (request, reply) => {
if (!isAuthorized(request, deps.config.apiToken)) {
return reply.code(401).send({error: 'unauthorized'});
}
@@ -52,7 +54,7 @@ export const registerGachaRoute = async (app, deps) => {
return reply
.header('Content-Type', 'video/mp4')
- .header('Content-Disposition', 'inline; filename="gacha.mp4"')
+ .header('Content-Disposition', `inline; filename="${name}.mp4"`)
.header('Cache-Control', 'no-store')
.header('X-Gacha-Rarity', String(gachaRequest.rarity))
.header('X-Render-Duration-Ms', String(result.durationMs))
diff --git a/renderer/src/server.js b/renderer/src/server.js
index 16cea36..dbba00c 100644
--- a/renderer/src/server.js
+++ b/renderer/src/server.js
@@ -3,6 +3,7 @@ import {loadConfig} from './config.js';
import {createRenderSemaphore} from './lib/render-semaphore.js';
import {getRemotionServeUrl} from './render/remotion-bundle.js';
import {closeGachaBrowser, renderGachaVideo, warmGachaBrowser} from './render/render-gacha.js';
+import {renderGenshinVideo} from './render/render-genshin.js';
import {renderWheelGif} from './render/render-gif.js';
import {registerGachaRoute} from './routes/gacha.js';
import {registerGifRoute} from './routes/gif.js';
@@ -19,6 +20,7 @@ import {registerHealthRoute} from './routes/health.js';
* @param {AppConfig} [options.config]
* @param {(request: WheelRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGif]
* @param {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGacha]
+ * @param {(request: GachaRenderRequest, renderOptions: {timeoutInMilliseconds: number}) => Promise<{buffer: Buffer, durationMs: number, byteLength: number}>} [options.renderGenshin]
* @param {boolean} [options.warmRenderers] Prepare the Remotion bundle and the gacha browser before serving.
*/
export const buildServer = async (options = {}) => {
@@ -41,7 +43,7 @@ export const buildServer = async (options = {}) => {
});
await registerHealthRoute(app);
- // One semaphore across both routes: they share the same browser and CPU.
+ // One semaphore across every route: renders share the CPU.
const semaphore = createRenderSemaphore(config.maxConcurrentRenders);
await registerGifRoute(app, {
config,
@@ -53,6 +55,12 @@ export const buildServer = async (options = {}) => {
renderGacha: options.renderGacha ?? renderGachaVideo,
semaphore,
});
+ await registerGachaRoute(app, {
+ config,
+ name: 'genshin',
+ renderGacha: options.renderGenshin ?? renderGenshinVideo,
+ semaphore,
+ });
// The gacha wish keeps one headless Chrome alive between renders.
app.addHook('onClose', closeGachaBrowser);
diff --git a/renderer/test/gacha-route.test.js b/renderer/test/gacha-route.test.js
index 035e509..9ccafdb 100644
--- a/renderer/test/gacha-route.test.js
+++ b/renderer/test/gacha-route.test.js
@@ -157,4 +157,34 @@ describe('POST /api/gacha', () => {
await app.close();
});
+
+ test('serves the Genshin-style wish on /api/genshin with the same contract', async () => {
+ /** @type {string[]} */
+ const calls = [];
+ const app = await build(
+ {},
+ {
+ renderGacha: async () => {
+ calls.push('gacha');
+ return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
+ },
+ renderGenshin: async (request) => {
+ calls.push(`genshin:${request.label}:${request.rarity}`);
+ return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
+ },
+ },
+ );
+
+ const response = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 5}});
+ const invalid = await app.inject({method: 'POST', url: '/api/genshin', payload: {label: 'Bún bò', rarity: 9}});
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers['content-type']).toBe('video/mp4');
+ expect(response.headers['content-disposition']).toBe('inline; filename="genshin.mp4"');
+ expect(response.headers['x-gacha-rarity']).toBe('5');
+ expect(invalid.statusCode).toBe(400);
+ expect(calls).toEqual(['genshin:Bún bò:5']);
+
+ await app.close();
+ });
});
diff --git a/renderer/test/genshin-timeline.test.js b/renderer/test/genshin-timeline.test.js
new file mode 100644
index 0000000..f7a1a52
--- /dev/null
+++ b/renderer/test/genshin-timeline.test.js
@@ -0,0 +1,110 @@
+import {describe, expect, test} from 'vitest';
+import {
+ createStarfield,
+ genshinTimeline,
+ genshinTotalSeconds,
+ getLabelFontSize,
+ emblemCenter,
+ getMeteorPoint,
+ getRankLetter,
+ getRarityPalette,
+ getShakeOffset,
+ getStarRevealTimes,
+ getTierEffects,
+ getTwinkle,
+} from '../src/remotion/genshin-timeline.js';
+
+describe('genshin timeline', () => {
+ test('phases run in order and finish before the clip ends', () => {
+ const tl = genshinTimeline;
+ expect(tl.meteorStart).toBeLessThan(tl.meteorEnd);
+ expect(tl.flashStart).toBeLessThan(tl.flashPeak);
+ expect(tl.meteorEnd).toBeLessThanOrEqual(tl.flashPeak);
+ expect(tl.flashPeak).toBeLessThan(tl.flashHoldEnd);
+ expect(tl.revealStart).toBeLessThanOrEqual(tl.flashHoldEnd);
+ expect(tl.flashHoldEnd).toBeLessThan(tl.flashEnd);
+ expect(tl.nameIn).toBeLessThan(tl.nameSettled);
+ const lastStar = getStarRevealTimes(5).at(-1) ?? 0;
+ expect(lastStar + 1).toBeLessThan(genshinTotalSeconds);
+ });
+
+ test.each([3, 4, 5])('reveals one star per rarity level for %i stars', (rarity) => {
+ const times = getStarRevealTimes(/** @type {3 | 4 | 5} */ (rarity));
+ expect(times).toHaveLength(rarity);
+ expect(times[0]).toBe(genshinTimeline.starsStart);
+ expect([...times].sort((a, b) => a - b)).toEqual(times);
+ });
+
+ test('each rarity has a distinct meteor colour', () => {
+ const glows = [3, 4, 5].map((rarity) => getRarityPalette(/** @type {3 | 4 | 5} */ (rarity)).glow);
+ expect(new Set(glows).size).toBe(3);
+ });
+
+ test('meteor flies in from off-screen upper left and lands on the rank emblem', () => {
+ const start = getMeteorPoint(0, 640, 360);
+ const end = getMeteorPoint(1, 640, 360);
+ expect(start.x).toBeLessThan(0);
+ expect(start.y).toBeLessThan(0);
+ expect(end.x).toBeCloseTo(640 * emblemCenter.x);
+ expect(end.y).toBeCloseTo(360 * emblemCenter.y);
+ expect(getMeteorPoint(2, 640, 360)).toEqual(end);
+ expect(getMeteorPoint(-1, 640, 360)).toEqual(start);
+ });
+
+ test('starfield is deterministic per seed', () => {
+ expect(createStarfield(5, 7)).toEqual(createStarfield(5, 7));
+ expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8));
+ });
+
+ test('stars twinkle with short bright flares', () => {
+ const [star] = createStarfield(1, 3);
+ if (!star) {
+ throw new Error('expected a star');
+ }
+ const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star));
+ expect(Math.max(...samples)).toBeGreaterThan(0.95);
+ expect(Math.min(...samples)).toBeLessThan(0.05);
+ const bright = samples.filter((value) => value > 0.5).length / samples.length;
+ expect(bright).toBeLessThan(0.4);
+ });
+
+ test('long labels get smaller text', () => {
+ expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360));
+ });
+
+ test('emblem shows B, A, S rank letters by rarity', () => {
+ expect([3, 4, 5].map((rarity) => getRankLetter(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['B', 'A', 'S']);
+ });
+
+ test('every numeric effect grows with rarity and 5★ unlocks the extras', () => {
+ const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity)));
+ for (const key of /** @type {const} */ ([
+ 'headScale',
+ 'trailSamples',
+ 'sparks',
+ 'shockwaves',
+ 'raysOpacity',
+ 'motes',
+ 'starFlare',
+ ])) {
+ expect(four?.[key], key).toBeGreaterThan(three?.[key] ?? Infinity);
+ expect(five?.[key], key).toBeGreaterThan(four?.[key] ?? Infinity);
+ }
+ expect(four?.shake).toBeGreaterThan(three?.shake ?? Infinity);
+ expect(five?.shake).toBeGreaterThan(four?.shake ?? Infinity);
+ expect(five?.skyFlood).toBeGreaterThan(four?.skyFlood ?? Infinity);
+ expect([three?.halo, four?.halo, five?.halo]).toEqual([false, false, true]);
+ expect(five).toMatchObject({starburst: true, counterRays: true, sheen: true});
+ expect(three?.sparkleRain).toBe(0);
+ expect(five?.sparkleRain).toBeGreaterThan(0);
+ });
+
+ test('impact shake decays to rest', () => {
+ expect(getShakeOffset(1, 2, 10)).toEqual({x: 0, y: 0});
+ expect(getShakeOffset(3, 2, 10)).toEqual({x: 0, y: 0});
+ expect(getShakeOffset(2.1, 2, 0)).toEqual({x: 0, y: 0});
+ const early = getShakeOffset(2.05, 2, 10);
+ const late = getShakeOffset(2.7, 2, 10);
+ expect(Math.hypot(early.x, early.y)).toBeGreaterThan(Math.hypot(late.x, late.y));
+ });
+});