diff --git a/src/sim/loyalty.ts b/src/sim/loyalty.ts new file mode 100644 index 0000000..b00c773 --- /dev/null +++ b/src/sim/loyalty.ts @@ -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; + 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; +} diff --git a/tests/sim/loyalty.test.ts b/tests/sim/loyalty.test.ts new file mode 100644 index 0000000..9c1ece2 --- /dev/null +++ b/tests/sim/loyalty.test.ts @@ -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); + }); +});