feat(sim): configurable loyalty program

This commit is contained in:
Victor Bodinaud committed 2026-10-08 12:32:18 +02:00
1 parent 8c2fff96d6
commit 7ed64476f8
2 files changed
+218

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
export type StampType = 'buy_new' | 'buy_used' | 'trade' | 'buy_back';
export type Reward =
| { kind: 'free_trade' }
| { kind: 'free_used_game'; maxValueCents: number }
| { kind: 'discount_percent'; percent: number }
| { kind: 'voucher'; valueCents: number };
export interface StampTrack {
stampType: StampType;
slots: number;
reward: Reward;
}
export interface LoyaltyProgram {
version: number;
tracks: StampTrack[];
}
export interface LoyaltyCard {
id: string;
programVersion: number;
stamps: number[]; // one counter per track of its program
pendingRewards: Reward[];
voucherCents: number;
}
export interface LoyaltyState {
programs: LoyaltyProgram[];
cards: Record<string, LoyaltyCard>;
nextCardNumber: number;
}
export const DEFAULT_TRACKS: StampTrack[] = [
{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } },
];
/** Estimated cost of a reward, used to rate how generous a program is. */
const FREE_TRADE_VALUE_CENTS = 800;
const AVERAGE_BASKET_CENTS = 3000;
export function createLoyaltyState(): LoyaltyState {
return { programs: [{ version: 1, tracks: DEFAULT_TRACKS }], cards: {}, nextCardNumber: 1 };
}
export function currentProgram(l: LoyaltyState): LoyaltyProgram {
return l.programs[l.programs.length - 1];
}
export function getProgram(l: LoyaltyState, version: number): LoyaltyProgram {
const program = l.programs.find((p) => p.version === version);
if (!program) throw new Error(`Unknown loyalty program version: ${version}`);
return program;
}
export function validateTracks(tracks: StampTrack[]): string | null {
if (tracks.length < 1 || tracks.length > 2) return '1 ou 2 types de tampons';
if (new Set(tracks.map((t) => t.stampType)).size !== tracks.length) return 'Types de tampons en double';
if (tracks.some((t) => t.slots < 3 || t.slots > 20)) return 'Entre 3 et 20 cases par type';
return null;
}
export function rewardValueCents(r: Reward): number {
switch (r.kind) {
case 'free_trade': return FREE_TRADE_VALUE_CENTS;
case 'free_used_game': return r.maxValueCents;
case 'discount_percent': return (AVERAGE_BASKET_CENTS * r.percent) / 100;
case 'voucher': return r.valueCents;
}
}
/** Reward value given back per stamp, in cents. */
export function generosity(program: LoyaltyProgram): number {
return program.tracks.reduce((sum, t) => sum + rewardValueCents(t.reward) / t.slots, 0);
}
export function returnVisitMultiplier(program: LoyaltyProgram): number {
return 1 + Math.min(generosity(program) / 100, 1);
}
export function joinProbability(program: LoyaltyProgram): number {
return Math.min(0.15 + generosity(program) / 1000, 0.6);
}
export function setProgram(l: LoyaltyState, tracks: StampTrack[]): { reputationDelta: number } {
const error = validateTracks(tracks);
if (error) throw new Error(error);
const previous = currentProgram(l);
const next: LoyaltyProgram = { version: previous.version + 1, tracks };
l.programs.push(next);
return { reputationDelta: generosity(next) < generosity(previous) ? -2 : 0 };
}
export function issueCard(l: LoyaltyState): LoyaltyCard {
const program = currentProgram(l);
const card: LoyaltyCard = {
id: `C${String(l.nextCardNumber).padStart(4, '0')}`,
programVersion: program.version,
stamps: program.tracks.map(() => 0),
pendingRewards: [],
voucherCents: 0,
};
l.nextCardNumber += 1;
l.cards[card.id] = card;
return card;
}
/** Stamps every track of the card matching `type`. Returns the rewards earned. */
export function addStamp(l: LoyaltyState, cardId: string, type: StampType): Reward[] {
const card = l.cards[cardId];
if (!card) throw new Error(`Unknown card: ${cardId}`);
const program = getProgram(l, card.programVersion);
const earned: Reward[] = [];
program.tracks.forEach((track, i) => {
if (track.stampType !== type) return;
card.stamps[i] += 1;
if (card.stamps[i] < track.slots) return;
card.stamps[i] = 0;
earned.push(track.reward);
if (track.reward.kind === 'voucher') card.voucherCents += track.reward.valueCents;
else card.pendingRewards.push(track.reward);
});
return earned;
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import {
addStamp, createLoyaltyState, currentProgram, generosity, issueCard,
joinProbability, returnVisitMultiplier, setProgram, StampTrack, validateTracks,
} from '../../src/sim/loyalty';
describe('loyalty', () => {
it('starts with 10 purchase stamps for a used game up to 10 €', () => {
const l = createLoyaltyState();
expect(currentProgram(l)).toEqual({
version: 1,
tracks: [{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } }],
});
expect(generosity(currentProgram(l))).toBe(100);
expect(returnVisitMultiplier(currentProgram(l))).toBe(2);
expect(joinProbability(currentProgram(l))).toBeCloseTo(0.25);
});
it('issues numbered cards on the current program', () => {
const l = createLoyaltyState();
const card = issueCard(l);
expect(card).toEqual({ id: 'C0001', programVersion: 1, stamps: [0], pendingRewards: [], voucherCents: 0 });
expect(issueCard(l).id).toBe('C0002');
});
it('earns the reward on the last stamp and resets the track', () => {
const l = createLoyaltyState();
const card = issueCard(l);
for (let i = 0; i < 9; i++) expect(addStamp(l, card.id, 'buy_new')).toEqual([]);
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'free_used_game', maxValueCents: 1000 }]);
expect(card.stamps).toEqual([0]);
expect(card.pendingRewards).toHaveLength(1);
});
it('ignores stamp types the card does not track', () => {
const l = createLoyaltyState();
const card = issueCard(l);
expect(addStamp(l, card.id, 'trade')).toEqual([]);
expect(card.stamps).toEqual([0]);
});
it('supports two independent stamp tracks', () => {
const l = createLoyaltyState();
setProgram(l, [
{ stampType: 'buy_new', slots: 3, reward: { kind: 'discount_percent', percent: 20 } },
{ stampType: 'trade', slots: 3, reward: { kind: 'free_trade' } },
]);
const card = issueCard(l);
addStamp(l, card.id, 'buy_new');
addStamp(l, card.id, 'trade');
addStamp(l, card.id, 'trade');
expect(card.stamps).toEqual([1, 2]);
expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]);
});
it('turns voucher rewards into card credit', () => {
const l = createLoyaltyState();
setProgram(l, [{ stampType: 'buy_used', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
const card = issueCard(l);
for (let i = 0; i < 3; i++) addStamp(l, card.id, 'buy_used');
expect(card.voucherCents).toBe(500);
expect(card.pendingRewards).toEqual([]);
});
it('validates tracks', () => {
const reward = { kind: 'free_trade' } as const;
const t = (stampType: StampTrack['stampType'], slots: number): StampTrack => ({ stampType, slots, reward });
expect(validateTracks([])).toBe('1 ou 2 types de tampons');
expect(validateTracks([t('buy_new', 5), t('trade', 5), t('buy_back', 5)])).toBe('1 ou 2 types de tampons');
expect(validateTracks([t('buy_new', 5), t('buy_new', 5)])).toBe('Types de tampons en double');
expect(validateTracks([t('buy_new', 2)])).toBe('Entre 3 et 20 cases par type');
expect(validateTracks([t('buy_new', 21)])).toBe('Entre 3 et 20 cases par type');
expect(validateTracks([t('buy_new', 3)])).toBeNull();
expect(() => setProgram(createLoyaltyState(), [])).toThrow('1 ou 2 types de tampons');
});
it('keeps existing cards on the rules they were issued with', () => {
const l = createLoyaltyState();
const oldCard = issueCard(l);
setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
for (let i = 0; i < 3; i++) addStamp(l, oldCard.id, 'buy_new');
expect(oldCard.stamps).toEqual([3]);
expect(oldCard.voucherCents).toBe(0);
expect(issueCard(l).programVersion).toBe(2);
});
it('penalizes reputation when the program becomes less generous', () => {
const l = createLoyaltyState();
const lessGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 20, reward: { kind: 'voucher', valueCents: 500 } }]);
expect(lessGenerous.reputationDelta).toBe(-2);
const moreGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 5, reward: { kind: 'voucher', valueCents: 1000 } }]);
expect(moreGenerous.reputationDelta).toBe(0);
});
});