Skip to content
Merged
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
13 changes: 11 additions & 2 deletions .github/workflows/docker-build-brico-bot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ on:
workflow_dispatch:
push:
branches:
- main
- "**"
paths:
- "backend/brico-bot/**"
- "common/bindings/**"
- "common/crafts/**"
- "common/bitcraft-bindings/**"
- "common/stelo/**"
- "package.json"
- "package-lock.json"

Expand Down Expand Up @@ -38,14 +39,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute image tags
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=branch,enable=${{ github.ref != 'refs/heads/main' }}
- name: Build and push image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: backend/brico-bot/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
github-token: "${{ secrets.GITHUB_TOKEN }}"
28 changes: 28 additions & 0 deletions backend/brico-bot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@
#BRICO_APP_HOST=http://127.0.0.1:3000
#BRICO_APP_MODULE=brico-app

# ── Stelo escrow (backs escrowed bounty currencies such as stelo-hex-coin) ───
# Off unless STELO_ENABLED is set; needs brico-app. STELO_CURRENCIES maps each escrowed currency to
# its Stelo ledger, the Debit escrow wallet the bot holds funds in (the bot must be a member of it
# with Write), and what happens to money owed to a payee with no payout wallet.
#STELO_ENABLED=1
#STELO_HOST=https://maincloud.spacetimedb.com
#STELO_MODULE=stelofinance
#STELO_CURRENCIES={"stelo-hex-coin":{"ledger":"Hex Coin","escrowAccountId":1,"unclaimed":{"action":"rollover","to":"hex-coin","afterDays":14}}}
# Prefix of every transfer memo/idempotency key; use a different one per deployment.
#STELO_REF_PREFIX=bx
#STELO_PAYOUT_INTERVAL_MS=86400000
#STELO_MIN_PAYOUT=1
#STELO_RETRY_BASE_MS=30000
#STELO_RETRY_MAX_MS=1800000
# How the bot authenticates to Stelo.
# permanent (default): the state dir holds one long-lived id token, in the file the bot's `stelo`
# connection reads (stelo_<host>_<module>_auth_token.token, see scripts/dev-stelo.mjs).
# refresh: a human logs in to Stelo's SpacetimeAuth client and puts the id token in
# stelo_id_token.token and the refresh token in stelo_refresh_token.token (state dir). The bot renews
# both every STELO_AUTH_REFRESH_MS, or sooner if the id token expires first.
#STELO_AUTH_MODE=permanent
#STELO_AUTH_AUTHORITY=https://auth.spacetimedb.com/oidc
#STELO_AUTH_CLIENT_ID=client_033wW7fObq5GPPc4ESCFsF
#STELO_AUTH_REFRESH_MS=43200000
# Discord webhook messaged once when Stelo auth fails (failed renewal, or Stelo rejecting the token)
# and again when it recovers. Unset: failures are only logged.
#STELO_AUTH_ALERT_WEBHOOK=

# ── bridge behaviour ────────────────────────────────────────────────────────
#BRICO_BOT_SNAPSHOT_INTERVAL_MS=1000
#BRICO_BOT_RECONCILE_INTERVAL_MS=300000
Expand Down
16 changes: 16 additions & 0 deletions backend/brico-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ The identity is stable across restarts because the connection token is persisted
`BRICO_BOT_STATE_DIR` (one file per uri+database, mode 0600 — these are credentials; delete a file
to get a fresh identity). Running as the database owner also works and needs no registration.

## Stelo escrow against a local Stelo

Escrowed bounty currencies (`stelo-hex-coin`) move money on Stelo. For local development, publish
Stelo with its `dev-auth` feature (`spacetime publish --build-options='--features dev-auth'`), then
from this directory run:

```sh
node scripts/dev-stelo.mjs
```

It mints the bot's `localhost` identity into the state dir, seeds Stelo as the database owner (the
bot as an app, a "Hex Coin" ledger, an escrow wallet, two payer wallets, a payee wallet) and prints
the `STELO_*` lines for `.env`. It is safe to re-run; set `PAYER_PLAYER_ID` / `PAYEE_PLAYER_ID` to
seed other BitCraft players as the wallets' owners. The bot also needs to be registered as a
`brico-app` service principal (see above), and `brico-app` published with the escrow tables.

## Layout

| Path | What it is |
Expand Down
4 changes: 3 additions & 1 deletion backend/brico-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"dev": "tsx watch src/main.ts",
"test": "node --import tsx --test \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit",
"discord:register-commands": "tsx src/discord/register-commands.ts"
"discord:register-commands": "tsx src/discord/register-commands.ts",
"discord:list-commands": "tsx scripts/list-commands.ts"
},
"dependencies": {
"@brico/bindings": "*",
Expand All @@ -20,6 +21,7 @@
"discord-api-types": "^0.38.55",
"discord-interactions": "^4.4.0",
"jose": "^6.2.0",
"oidc-client-ts": "^3.5.0",
"prom-client": "^15.1.3",
"sharp": "^0.34.5",
"spacetimedb": "2.10.2",
Expand Down
43 changes: 43 additions & 0 deletions backend/brico-bot/scripts/list-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* list-commands.ts — prints every registered Discord command leaf with its path and arguments
* (`npm run discord:list-commands --workspace @brico/bot`), grouped by scope.
*/
import {ApplicationCommandOptionType} from "discord-api-types/v10";

import {commands} from "../src/discord/commands";
import type {LeafCommand} from "../src/discord/commands/registry";

const TYPE_NAMES: Partial<Record<ApplicationCommandOptionType, string>> = {
[ApplicationCommandOptionType.String]: "string",
[ApplicationCommandOptionType.Integer]: "integer",
[ApplicationCommandOptionType.Boolean]: "boolean",
[ApplicationCommandOptionType.User]: "user",
[ApplicationCommandOptionType.Channel]: "channel",
[ApplicationCommandOptionType.Role]: "role",
[ApplicationCommandOptionType.Mentionable]: "mentionable",
[ApplicationCommandOptionType.Number]: "number",
[ApplicationCommandOptionType.Attachment]: "attachment",
};

function formatArg(option: NonNullable<LeafCommand["options"]>[number]): string {
const choices = "choices" in option ? option.choices : undefined;
const type = choices?.length ? choices.map(c => c.value).join("|") : TYPE_NAMES[option.type] ?? String(option.type);
const text = `${option.name}: ${type}`;
return option.required ? `<${text}>` : `[${text}]`;
}

function formatLeaf(leaf: LeafCommand): string {
return ["/" + leaf.path.join(" "), ...(leaf.options ?? []).map(formatArg)].join(" ");
}

function printSection(heading: string | null, leaves: LeafCommand[]): void {
if (leaves.length === 0) return;
if (heading) console.log(`${heading}:`);
for (const leaf of [...leaves].sort((a, b) => a.path.join(" ").localeCompare(b.path.join(" ")))) {
console.log(formatLeaf(leaf));
}
}

printSection(null, commands.filter(leaf => leaf.scope === undefined));
printSection("guild only", commands.filter(leaf => leaf.scope === "guild"));
printSection("dm only", commands.filter(leaf => leaf.scope === "dm"));
5 changes: 4 additions & 1 deletion backend/brico-bot/src/app/app-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface BountyRuleSpec {
filter: FilterNode;
value: BountyRuleValue;
private: boolean;
/** The paying Stelo wallet for an escrowed currency. */
fundingLinkId: bigint | undefined;
}

/**
Expand All @@ -43,6 +45,7 @@ export interface ExistingBountyAssignment {
currency: string;
assignedByAccountIdentity: Identity;
private: boolean;
sticky: boolean;
}

/** A (payer, payee, currency) ledger key. */
Expand Down Expand Up @@ -187,7 +190,7 @@ export function createAppCache(log: Logger): AppCache {
const filter = parseRuleFilter(rule);
if (!filter) continue;
const key = rule.accountIdentity.toHexString();
const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private};
const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private, fundingLinkId: rule.fundingLinkId};
const specs = byAccount.get(key);
if (specs) specs.push(spec);
else byAccount.set(key, [spec]);
Expand Down
104 changes: 94 additions & 10 deletions backend/brico-bot/src/app/bounty-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import type {DbConnection} from "@brico/bindings/brico-app";
import type {CraftMeta} from "@brico/bindings/prism/types";
import {matchAll} from "@brico/crafts/filter";
import assert from "node:assert/strict";
import {test} from "node:test";
import {describe, test} from "node:test";
import {Identity, Timestamp} from "spacetimedb";

import type {RecipeIndex, RecipeStatic} from "../game-data/recipes.ts";
import type {Logger} from "../log.ts";
import {readSnapshot} from "../relay/prism.ts";
import {createRowCache} from "../relay/row-cache.ts";
import type {FundingDecision} from "../stelo/funding.ts";
import {fakeTable} from "../testing/fake-table.ts";
import {createAppCache} from "./app-cache.ts";
import {bountyScope, createBountyEngine} from "./bounty-sink.ts";
import {bountyScope, createBountyEngine, type EscrowFunding} from "./bounty-sink.ts";
import type {BricoAppConnection} from "./connection.ts";
import {createEscrowCache} from "./escrow-cache.ts";

const quiet: Logger = {debug() {}, info() {}, warn() {}, error() {}, child: () => quiet};

Expand Down Expand Up @@ -104,7 +106,7 @@ function fakeModule(feeds: ReturnType<typeof createAppCache>, seed: number) {
const [into, other] = args.private
? [tables.allPrivateCraftBountyAssignment, tables.allCraftBountyAssignment]
: [tables.allCraftBountyAssignment, tables.allPrivateCraftBountyAssignment];
into.upsert({craftId: args.craftId, ratioNumerator: args.ratioNumerator, ratioDenominator: args.ratioDenominator, currency: args.currency, assignedByAccountIdentity: args.assignedByAccountIdentity, assignedAt: args.assignedAt, updatedAt: args.updatedAt});
into.upsert({craftId: args.craftId, ratioNumerator: args.ratioNumerator, ratioDenominator: args.ratioDenominator, currency: args.currency, assignedByAccountIdentity: args.assignedByAccountIdentity, assignedAt: args.assignedAt, updatedAt: args.updatedAt, sticky: args.sticky});
other.remove(String(args.craftId));
for (const baseline of args.baselines) {
if (!find(tables.allCraftBountyEntitlement, r => r.craftId === args.craftId && r.playerId === baseline.playerId && r.currency === args.currency)) {
Expand Down Expand Up @@ -162,7 +164,7 @@ function fakeModule(feeds: ReturnType<typeof createAppCache>, seed: number) {
};
}

function createSide(options: {forceFull: boolean; seed: number}) {
function createSide(options: {forceFull: boolean; seed: number; funding?: EscrowFunding}) {
const rowCache = createRowCache(RECIPES);
const prism = {
craftMeta: fakeTable<CraftMeta>(r => String(r.entityId), rowCache.craftMeta),
Expand All @@ -176,7 +178,7 @@ function createSide(options: {forceFull: boolean; seed: number}) {
const appCache = createAppCache(quiet);
const module = fakeModule(appCache, options.seed);
const app = {connection: {connection: module.conn}, isLive: true} as unknown as BricoAppConnection;
const engine = createBountyEngine(app, appCache, quiet);
const engine = createBountyEngine(app, appCache, quiet, options.funding);
let started = false;

return {
Expand Down Expand Up @@ -273,8 +275,9 @@ function generator(seed: number) {
},
() => {
const id = linkId++ % 5n, player = pick(PLAYERS), account = pick(ACCOUNTS), revoked = chance(0.2);
const revokedAt = revoked ? Timestamp.now() : undefined;
return side => side.module.tables.allLinkedIntegration.upsert({
id, accountIdentity: account, provider: "bitcraft-ea2", externalId: player.toString(), revokedAt: revoked ? Timestamp.now() : undefined,
id, accountIdentity: account, provider: "bitcraft-ea2", externalId: player.toString(), revokedAt,
});
},
() => {
Expand Down Expand Up @@ -306,18 +309,18 @@ function generator(seed: number) {
const spec = chance(0.4)
? {tag: "ClaimMembership", value: {claimEntityId: pick(CLAIMS.slice(1)), requiredAccess: pick(["member", "build", "officer", "owner"])}}
: {tag: "EffortThreshold", value: {allCurrencies: chance(0.5), threshold: BigInt(20 + int(300))}};
const numerator = BigInt(1 + int(3)), denominator = BigInt(pick([20, 40, 50]));
const numerator = BigInt(1 + int(3)), denominator = BigInt(pick([20, 40, 50])), updatedAt = Timestamp.now();
return (side: Side) => side.module.tables.allLoyaltyRule.upsert({
id, payerAccountIdentity: payer, currency, spec, bonusRatioNumerator: numerator, bonusRatioDenominator: denominator, updatedAt: Timestamp.now(),
id, payerAccountIdentity: payer, currency, spec, bonusRatioNumerator: numerator, bonusRatioDenominator: denominator, updatedAt,
});
}),
() => {
const id = String(1 + int(5));
return side => side.module.tables.allLoyaltyRule.remove(id);
},
() => {
const id = loyaltyId++ % 4n, payer = pick(ACCOUNTS), player = pick(PLAYERS), currency = pick(CURRENCIES);
return side => side.module.tables.allLoyaltyReward.upsert({id, payerAccountIdentity: payer, payeePlayerId: player, currency, ratioNumerator: 21n, ratioDenominator: 20n, updatedAt: Timestamp.now()});
const id = loyaltyId++ % 4n, payer = pick(ACCOUNTS), player = pick(PLAYERS), currency = pick(CURRENCIES), updatedAt = Timestamp.now();
return side => side.module.tables.allLoyaltyReward.upsert({id, payerAccountIdentity: payer, payeePlayerId: player, currency, ratioNumerator: 21n, ratioDenominator: 20n, updatedAt});
},
() => {
const id = String(pick([0n, 1n, 2n, 3n]));
Expand Down Expand Up @@ -444,3 +447,84 @@ test("a closed craft keeps its stored assignment until prism deletes the craft o
side.prism.craftMeta.remove("1");
assert.deepEqual(await side.tick(), ["clear 1"]);
});

test("editing or deleting a rule leaves crafts that already have an assignment alone, and only new crafts follow it", async () => {
const side = createSide({forceFull: false, seed: 1});
bountiedCraft(side);
assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/4 hex-coin")));

const rule = side.module.tables.allBountyRule.rows.get("rule")!;
side.module.tables.allBountyRule.upsert({...rule, value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin"}}});
assert.deepEqual(await side.tick(), [], "a rate edit does not reach the assigned craft");

side.prism.craftMeta.upsert(craftMeta(2n, 200n, 0n, 1, 1, "Active", FRESH));
assert.ok((await side.tick()).some(write => write.startsWith("assign 2 1/2 hex-coin")), "a new craft gets the edited rate");

side.module.tables.allBountyRule.upsert({...rule, deletedAt: Timestamp.now()});
assert.deepEqual(await side.tick(), [], "deleting the rule clears nothing that is already assigned");
});

test("removing an override sends its craft back to its rules", async () => {
const side = createSide({forceFull: false, seed: 1});
bountiedCraft(side);
await side.tick();
side.module.tables.allCraftBountyOverride.upsert({craftId: 1n, accountIdentity: ACCOUNTS[0], ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin", private: false});
assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/2 hex-coin")));

side.module.tables.allCraftBountyOverride.remove("1");
assert.ok((await side.tick()).some(write => write.startsWith("assign 1 1/4 hex-coin")), "the rule's rate applies again");
});

describe("escrowed bounties", () => {
function escrowedCraft(decide: () => FundingDecision) {
const gateCalls: string[] = [];
const funding: EscrowFunding = {
escrow: createEscrowCache(),
status: {report() {}, clear() {}, flush() {}},
gate: {
status: () => ({state: "waiting"}),
prepare(craftId, request, effortCovered) {
gateCalls.push(`${craftId} ${request.ratioNumerator}/${request.ratioDenominator} ${request.fundingLinkId} ${effortCovered}`);
return decide();
},
},
};
const side = createSide({forceFull: false, seed: 1, funding});
side.module.tables.allLinkedIntegration.upsert({id: 1n, accountIdentity: ACCOUNTS[0], provider: "bitcraft-ea2", externalId: "200", revokedAt: undefined});
side.module.tables.allBountyRule.upsert({
id: "rule", accountIdentity: ACCOUNTS[0], priority: 0, filterJson: JSON.stringify(matchAll()), private: false, fundingLinkId: 9n,
value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 4n, currency: "stelo-hex-coin"}}, deletedAt: undefined,
});
side.prism.craftMeta.upsert(craftMeta(1n, 200n, 0n, 1, 1, "Active", FRESH));
side.prism.craftContribution.upsert({craftId: 1n, playerId: 201n, contribution: 30});
return {side, gateCalls};
}

test("an unfunded bounty is neither assigned, priced nor shown until the gate says it is ready", async () => {
let decision: FundingDecision = "wait";
const {side, gateCalls} = escrowedCraft(() => decision);

assert.deepEqual(await side.tick(), []);
assert.deepEqual(await side.tick(), [], "still waiting");
assert.equal(side.rowCache.rows.get("1")?.subject.payout, null);
assert.ok(gateCalls.length >= 2, "the gate is asked again every tick");
assert.match(gateCalls[0]!, /^1 1\/4 9 /, "with the rule's rate and funding wallet");

decision = "ready";
const funded = await side.tick();
assert.ok(funded.some(write => write.startsWith("assign 1 1/4 stelo-hex-coin")), "assigned once funded");
await side.tick();
assert.notEqual(side.rowCache.rows.get("1")?.subject.payout, null);
});

test("a craft held for another payer keeps its assignment and nothing is rewritten", async () => {
let decision: FundingDecision = "ready";
const {side} = escrowedCraft(() => decision);
assert.ok((await side.tick()).some(write => write.startsWith("assign 1 ")));

decision = "keep";
const rule = side.module.tables.allBountyRule.rows.get("rule")!;
side.module.tables.allBountyRule.upsert({...rule, value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 2n, currency: "stelo-hex-coin"}}});
assert.deepEqual((await side.tick()).filter(write => write.startsWith("assign")), []);
});
});
15 changes: 14 additions & 1 deletion backend/brico-bot/src/app/bounty-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {ClaimMember} from "@brico/bindings/prism/types";
import assert from "node:assert/strict";
import {describe, it} from "node:test";
import {Identity, Timestamp} from "spacetimedb";
import {assignmentBaselines, FRESH_CRAFT_WINDOW_MS, resolveAutomaticBonus} from "./bounty-sink.ts";
import {assignmentBaselines, currencySwitchBaselines, FRESH_CRAFT_WINDOW_MS, resolveAutomaticBonus} from "./bounty-sink.ts";

const PAYER = new Identity(1n);
const PLAYER_ID = 500n;
Expand Down Expand Up @@ -177,3 +177,16 @@ describe("assignmentBaselines", () => {
assert.deepEqual(assignmentBaselines(undefined, firstSeen, firstSeen + 60_000n), []);
});
});

describe("currencySwitchBaselines", () => {
it("protects all existing effort, however recently the craft was first seen", () => {
assert.deepEqual(currencySwitchBaselines(new Map([[500n, 120n], [501n, 30n]])), [
{playerId: 500n, effort: 120n},
{playerId: 501n, effort: 30n},
]);
});

it("is empty for a craft nobody has contributed to", () => {
assert.deepEqual(currencySwitchBaselines(undefined), []);
});
});
Loading
Loading