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