Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions docs/modules/gamification.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,20 @@ because a rate provider is slow would be a worse failure than undercounting thei
What counts toward a rank is the raw stake, not what a bonus engine weighted it at. Weighting
belongs to a bonus a player happens to hold; a player's standing must not depend on which bonus
they are carrying. Which products count - casino, live casino, sportsbook - is the operator's
setting, and an empty setting counts every bet.
setting.

Rakeback and the rank challenge each have a product list of their own, and a new ladder's lists
leave sportsbook out until the operator adds it. A ladder configured before these lists existed
was upgraded to empty ones, so it keeps paying on every product it did. Rakeback pays only on a
stake both the rank's list and the rakeback list admit. The rank challenge does not need the
ladder's settings row: without one it counts the default list. An empty list counts every
product here too. Both lists are set on `PUT /backoffice/promo/ranks/config`, so changing one
leave sportsbook out until the operator adds it. Rakeback pays only on a stake both the rank's
list and the rakeback list admit. The rank challenge does not need the ladder's settings row:
without one it counts the default list. Every product list - the rank's, rakeback's, the rank
challenge's and the streak's - names its products, and a save with an empty list, a repeated
product or an unknown one is refused. An empty list used to count every product, so a list saved
empty before that rule was given all four on upgrade and keeps counting what it did. An instance
still on the previous release can save an empty list during a rolling upgrade, so a stored empty
list still counts every product; a later release backfills those and refuses them in the
database. A list naming only unknown products was kept and counts nothing until the operator
saves a valid one.
Both lists are set on `PUT /backoffice/promo/ranks/config`, so changing one
writes the ladder's settings row with it; a save that leaves a list or `periodicMinimumWager` out
keeps the stored one, and only an explicit `null` clears the minimum.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { createTestDb, type TestDb } from '@openora/core/testing';
import { mock } from '../../../testing/mock.js';
import type { WagerProduct, WalletCommands } from '@openora/core/contracts';
import { migrate } from '../migrate.js';
import {
promoPlayerRank,
promoRankConfig,
promoRankTier,
promoStreakConfig,
} from '../schema/index.js';
import { DEFAULT_PAYOUT_ANCHORS } from '../contract/index.js';
import { RakebackService } from '../service/rakeback.service.js';

const MIGRATIONS = new URL('../drizzle/migrations/', import.meta.url);
const EVERY_PRODUCT = ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[];

const BACKFILL_TAG = (
JSON.parse(readFileSync(fileURLToPath(new URL('meta/_journal.json', MIGRATIONS)), 'utf8')) as {
entries: { tag: string }[];
}
).entries
.map((e) => e.tag)
.find((tag) => tag.endsWith('_rank_streak_products_required'));

const BACKFILL_SQL = readFileSync(
fileURLToPath(new URL(`${BACKFILL_TAG}.sql`, MIGRATIONS)),
'utf8',
).split('--> statement-breakpoint');

let db: TestDb;
const credit = vi.fn<WalletCommands['credit']>();
let rakeback: RakebackService;

beforeAll(async () => {
db = await createTestDb([migrate]);
rakeback = new RakebackService(() => mock<WalletCommands>({ credit }), { warn: vi.fn() });
});

afterAll(() => db.drop());

beforeEach(async () => {
vi.clearAllMocks();
credit.mockResolvedValue({ ok: true, moved: true, transactionId: randomUUID(), newBalance: '0' });
await db.drizzle.db.delete(promoPlayerRank);
await db.drizzle.db.delete(promoRankTier);
await db.drizzle.db.delete(promoRankConfig);
await db.drizzle.db.delete(promoStreakConfig);
});

async function asBeforeTheUpgrade() {
await db.drizzle.db.execute(
sql`ALTER TABLE "promo_rank_config" ALTER COLUMN "eligible_products" SET DEFAULT '{}'`,
);
await db.drizzle.db.execute(
sql`ALTER TABLE "promo_streak_config" ALTER COLUMN "eligible_products" SET DEFAULT '{}'`,
);
}

async function upgrade() {
for (const statement of BACKFILL_SQL) {
await db.drizzle.db.execute(sql.raw(statement));
}
}

const saveRankConfig = (
lists: Pick<
typeof promoRankConfig.$inferInsert,
'eligibleProducts' | 'rakebackEligibleProducts' | 'rankChallengeEligibleProducts'
>,
) =>
db.drizzle.db.insert(promoRankConfig).values({ ...lists, payoutAnchors: DEFAULT_PAYOUT_ANCHORS });

const saveStreakConfig = (eligibleProducts: string[]) =>
db.drizzle.db
.insert(promoStreakConfig)
.values({ currency: 'USDT', dailyMinWager: '10', eligibleProducts });

async function playerOnTier() {
const [tier] = await db.drizzle.db
.insert(promoRankTier)
.values({
key: 'bronze',
name: 'Bronze',
position: 0,
currency: 'USDT',
wagerThreshold: '0',
rakebackPercent: '1',
})
.returning({ id: promoRankTier.id });
const userId = randomUUID();
await db.drizzle.db
.insert(promoPlayerRank)
.values({ userId, currency: 'USDT', tierId: tier?.id });
return userId;
}

const settleSportsbookBet = (userId: string) =>
db.drizzle.db.transaction((tx) =>
rakeback.recordSettledWager(tx, {
userId,
currency: 'USDT',
realAmount: '100',
context: { provider: 'sportsbook', product: 'sportsbook' },
round: { providerName: 'sportsbook:provider', externalRoundId: randomUUID() },
qualifiedAt: new Date(),
}),
);

describe('product lists on upgrade', () => {
it('gives every empty list all products, so a sportsbook bet still earns rakeback', async () => {
await asBeforeTheUpgrade();
await saveRankConfig({
eligibleProducts: [],
rakebackEligibleProducts: [],
rankChallengeEligibleProducts: [],
});
await saveStreakConfig([]);
const userId = await playerOnTier();

await upgrade();
await settleSportsbookBet(userId);

const [rank] = await db.drizzle.db.select().from(promoRankConfig);
const [streak] = await db.drizzle.db.select().from(promoStreakConfig);
expect(rank).toMatchObject({
eligibleProducts: EVERY_PRODUCT,
rakebackEligibleProducts: EVERY_PRODUCT,
rankChallengeEligibleProducts: EVERY_PRODUCT,
});
expect(streak?.eligibleProducts).toEqual(EVERY_PRODUCT);
expect(credit).toHaveBeenCalledTimes(1);
});

it('keeps a list the operator named, even one naming no product the platform has', async () => {
await asBeforeTheUpgrade();
await saveRankConfig({
eligibleProducts: ['slots'],
rakebackEligibleProducts: ['casino'],
rankChallengeEligibleProducts: ['pvp', 'sportsbook'],
});
await saveStreakConfig(['live-casino']);

await upgrade();

const [rank] = await db.drizzle.db.select().from(promoRankConfig);
const [streak] = await db.drizzle.db.select().from(promoStreakConfig);
expect(rank).toMatchObject({
eligibleProducts: ['slots'],
rakebackEligibleProducts: ['casino'],
rankChallengeEligibleProducts: ['pvp', 'sportsbook'],
});
expect(streak?.eligibleProducts).toEqual(['live-casino']);
});

it('still counts every product on a list the previous release saves empty after the upgrade', async () => {
await asBeforeTheUpgrade();
await upgrade();
await saveRankConfig({
eligibleProducts: [],
rakebackEligibleProducts: [],
rankChallengeEligibleProducts: [],
});
const userId = await playerOnTier();

await settleSportsbookBet(userId);

expect(credit).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const LADDER = {
{ key: 'gold', name: 'Gold', wagerThreshold: '50000', rakebackPercent: '5' },
],
config: {
eligibleProducts: [],
eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[],
rewards: {},
payoutAnchors: DEFAULT_PAYOUT_ANCHORS,
payInPlayerCurrency: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ describe('which products count toward the Rank Challenge', () => {

it('counts a sportsbook stake once the operator adds it to the list', async () => {
const { account } = await seedPlayerWithUser(db);
await db.drizzle.db
.insert(promoRankConfig)
.values({ rankChallengeEligibleProducts: ['casino', 'sportsbook'] });
await db.drizzle.db.insert(promoRankConfig).values({
eligibleProducts: ['casino'],
rankChallengeEligibleProducts: ['casino', 'sportsbook'],
});

await wagerOn(account.id, 'sportsbook');

Expand All @@ -229,7 +230,9 @@ describe('which products count toward the Rank Challenge', () => {

it('leaves out a product the operator removed from the list', async () => {
const { account } = await seedPlayerWithUser(db);
await db.drizzle.db.insert(promoRankConfig).values({ rankChallengeEligibleProducts: ['pvp'] });
await db.drizzle.db
.insert(promoRankConfig)
.values({ eligibleProducts: ['casino'], rankChallengeEligibleProducts: ['pvp'] });

await wagerOn(account.id, 'casino');

Expand All @@ -238,7 +241,9 @@ describe('which products count toward the Rank Challenge', () => {

it('counts every product when the list is empty', async () => {
const { account } = await seedPlayerWithUser(db);
await db.drizzle.db.insert(promoRankConfig).values({ rankChallengeEligibleProducts: [] });
await db.drizzle.db
.insert(promoRankConfig)
.values({ eligibleProducts: ['casino'], rankChallengeEligibleProducts: [] });

await wagerOn(account.id, 'sportsbook');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ beforeEach(async () => {
},
],
config: {
eligibleProducts: [],
eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'],
rewards: { levelUp: LEVEL_UP_TERMS, daily: DAILY_TERMS },
payoutAnchors: DEFAULT_PAYOUT_ANCHORS,
payInPlayerCurrency: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { createTestDb, type TestDb } from '@openora/core/testing';
import { mock } from '../../../testing/mock.js';
import type { WalletCommands } from '@openora/core/contracts';
import type { WagerProduct, WalletCommands } from '@openora/core/contracts';
import { migrate } from '../migrate.js';
import { promoPlayerRank, promoRankConfig, promoRankTier } from '../schema/index.js';
import { DEFAULT_PAYOUT_ANCHORS, DEFAULT_RANK_PERK_PRODUCTS } from '../contract/index.js';
Expand All @@ -15,6 +15,7 @@ import { RakebackService } from '../service/rakeback.service.js';
const MIGRATIONS = new URL('../drizzle/migrations/', import.meta.url);
const ADDED_IN = '0018_aberrant_killer_shrike';
const BACKFILLED_IN = '0019_rank_perk_products_backfill';
const EVERY_PRODUCT = ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[];

const JOURNAL_TAGS = (
JSON.parse(readFileSync(fileURLToPath(new URL('meta/_journal.json', MIGRATIONS)), 'utf8')) as {
Expand All @@ -27,16 +28,16 @@ const sqlOf = (tag: string) =>
'--> statement-breakpoint',
);

const UPGRADE_SQL = JOURNAL_TAGS.slice(
JOURNAL_TAGS.indexOf(ADDED_IN),
JOURNAL_TAGS.indexOf(BACKFILLED_IN) + 1,
).flatMap(sqlOf);
const BACKFILL_SQL = [
BACKFILLED_IN,
JOURNAL_TAGS.find((tag) => tag.endsWith('_rank_streak_products_required')) ?? '',
].flatMap(sqlOf);

const LADDER = {
currency: 'USDT',
tiers: [{ key: 'bronze', name: 'Bronze', wagerThreshold: '0', rakebackPercent: '1' }],
config: {
eligibleProducts: [],
eligibleProducts: EVERY_PRODUCT,
rewards: {},
payoutAnchors: DEFAULT_PAYOUT_ANCHORS,
payInPlayerCurrency: false,
Expand All @@ -63,18 +64,25 @@ beforeEach(async () => {
await db.drizzle.db.delete(promoRankConfig);
});

async function upgradeFromBeforeProductLists() {
async function addProductLists() {
await db.drizzle.db.execute(sql`ALTER TABLE "promo_rank_config"
DROP COLUMN "rakeback_eligible_products", DROP COLUMN "rank_challenge_eligible_products"`);
for (const statement of UPGRADE_SQL) {
for (const statement of sqlOf(ADDED_IN)) {
await db.drizzle.db.execute(sql.raw(statement));
}
}

async function backfill() {
for (const statement of BACKFILL_SQL) {
await db.drizzle.db.execute(sql.raw(statement));
}
}

describe('rank perk product lists on upgrade', () => {
it('keeps every product on a ladder configured before the lists existed', async () => {
await seedRankLadder(db.drizzle.db, LADDER);
await upgradeFromBeforeProductLists();
await addProductLists();
await backfill();
const userId = randomUUID();
const [tier] = await db.drizzle.db
.select({ id: promoRankTier.id })
Expand All @@ -97,32 +105,31 @@ describe('rank perk product lists on upgrade', () => {

const [config] = await db.drizzle.db.select().from(promoRankConfig);
expect(config).toMatchObject({
rakebackEligibleProducts: [],
rankChallengeEligibleProducts: [],
rakebackEligibleProducts: EVERY_PRODUCT,
rankChallengeEligibleProducts: EVERY_PRODUCT,
});
expect(credit).toHaveBeenCalledTimes(1);
});

it('keeps a list the operator saved when the backfill runs after it', async () => {
await upgradeFromBeforeProductLists();
await addProductLists();
await seedRankLadder(db.drizzle.db, LADDER);
await db.drizzle.db
.update(promoRankConfig)
.set({ rakebackEligibleProducts: ['casino', 'sportsbook'] });

for (const statement of sqlOf('0019_rank_perk_products_backfill')) {
await db.drizzle.db.execute(sql.raw(statement));
}
await backfill();

const [config] = await db.drizzle.db.select().from(promoRankConfig);
expect(config).toMatchObject({
rakebackEligibleProducts: ['casino', 'sportsbook'],
rankChallengeEligibleProducts: [],
rankChallengeEligibleProducts: EVERY_PRODUCT,
});
});

it('gives a ladder configured after the upgrade the narrow default', async () => {
await upgradeFromBeforeProductLists();
await addProductLists();
await backfill();
await seedRankLadder(db.drizzle.db, LADDER);

const [config] = await db.drizzle.db.select().from(promoRankConfig);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { createTestDb, type TestDb } from '@openora/core/testing';
import { makeAuditWriter, mock } from '../../../testing/mock.js';
import type { ExchangeRateReader, WagerContext } from '@openora/core/contracts';
import type { ExchangeRateReader, WagerContext, WagerProduct } from '@openora/core/contracts';
import { migrate } from '../migrate.js';
import {
promoPlayerRank,
Expand Down Expand Up @@ -43,7 +43,7 @@ const LADDER = {
},
],
config: {
eligibleProducts: [],
eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[],
rewards: {},
payoutAnchors: DEFAULT_PAYOUT_ANCHORS,
payInPlayerCurrency: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,14 @@ describe('setting the streak config', () => {
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.path).toEqual(['milestones']);
});

it.each([
['no product', []],
['a repeated product', ['casino', 'casino']],
['an unknown product', ['slots']],
])('rejects a product list with %s', (_, eligibleProducts) => {
const result = SetStreakConfigInputSchema.safeParse({ ...config(3), eligibleProducts });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.path.slice(0, 1)).toEqual(['eligibleProducts']);
});
});
Loading
Loading