Files
Flight-Ops/lib/game/penalty.test.ts

80 lines
3.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { abandonPenalty, abandonPenaltyPct, ABANDON_PENALTY_MIN, ABANDON_PENALTY_SPAN, elapsedFractionFor } from './penalty';
// The v0.12.0 abandon penalty: 15% of the payout right after creation, scaling
// linearly to 60% at the deadline; expired packages cost nothing. Pure math —
// the server action and the client preview both call these same functions.
const T0 = 1_750_000_000_000;
const WINDOW_MS = 10 * 3_600_000; // created → deadline span
const REWARD = 1_000;
const pkg = (status: string, over: Partial<{ reward: number; createdSimTime: number; deadlineSimTime: number }> = {}) => ({
status,
reward: REWARD,
createdSimTime: T0,
deadlineSimTime: T0 + WINDOW_MS,
...over,
});
describe('abandonPenaltyPct', () => {
it('is exactly 15% at the start of the window (fraction 0)', () => {
expect(abandonPenaltyPct(0)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
});
it('is exactly 60% at the deadline (fraction 1)', () => {
expect(abandonPenaltyPct(1)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
});
it('clamps out-of-range fractions to [0, 1]', () => {
expect(abandonPenaltyPct(-5)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
expect(abandonPenaltyPct(2)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
});
it('is monotonic non-decreasing across the window', () => {
let prev = -1;
for (let i = 0; i <= 100; i++) {
const pct = abandonPenaltyPct(i / 100);
expect(pct).toBeGreaterThanOrEqual(prev);
prev = pct;
}
});
});
describe('elapsedFractionFor', () => {
it('is 0 at creation, 0.5 at the midpoint, 1 at and past the deadline', () => {
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0)).toBe(0);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS / 2)).toBeCloseTo(0.5, 9);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS)).toBe(1);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS * 3)).toBe(1); // clamped
});
it('treats a zero/negative span as fully elapsed (max penalty)', () => {
expect(elapsedFractionFor(T0, T0, T0)).toBe(1);
expect(elapsedFractionFor(T0 + 5, T0, T0 + 5)).toBe(1);
});
});
describe('abandonPenalty', () => {
it('charges exactly 15% of the payout right after creation', () => {
expect(abandonPenalty(pkg('loaded'), T0)).toBe(Math.round(REWARD * 0.15 * 100) / 100);
});
it('scales to exactly 37.5% at the midpoint and 60% at/past the deadline', () => {
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS / 2)).toBe(Math.round(REWARD * 0.375 * 100) / 100);
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS)).toBe(Math.round(REWARD * 0.6 * 100) / 100);
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS * 2)).toBe(Math.round(REWARD * 0.6 * 100) / 100); // clamped
});
it('is free for an already-expired package regardless of the clock', () => {
expect(abandonPenalty(pkg('expired'), T0)).toBe(0);
expect(abandonPenalty(pkg('expired'), T0 + WINDOW_MS * 5)).toBe(0);
});
it('rounds to cents and scales with the reward, not a fixed amount', () => {
// €999.99 × 15% = 149.9985 → 150.00; €333 × 60% = 199.8 exactly.
expect(abandonPenalty(pkg('loaded', { reward: 999.99 }), T0)).toBe(150);
expect(abandonPenalty(pkg('loaded', { reward: 333 }), T0 + WINDOW_MS)).toBe(199.8);
});
});