feat(sim): configurable loyalty program
This commit is contained in:
1 parent
8c2fff96d6
commit
7ed64476f8
2 files changed
+218
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user