Dansday

The Month XP Became Money

Published on Jul 31, 2026

July 2026 has a clean split down the middle. At the day job I spent the month deleting numbers that were not true. On my own Discord bot I spent twelve days building four new ways to make a number mean less.

3cat-Sdn-Bhd/3cat                30 pull requests merged   125 contributions (private)
dansday-com/dansday-discord-bot  10 opened, 9 merged, 1 closed   186 contributions
dansday-com/dansday-main         nothing

Three hundred and eleven contributions. Nothing at all on this website, which is the first month since February with a zero on that line — and the bot did not stir until the 20th, then produced 186 commits in twelve days.

Ten pull requests on the first of the month

1 July merged ten pull requests, which is what the end of a sprint looks like. Two of them are the same fix from opposite ends, and they are the best work in this article.

The shop records a row in a transaction table for money movements. Starting a payment through the eGHL gateway used to create one immediately:

private function createPendingTransaction(string $serviceID, string $paymentID, float $amount): void
{
    $transactionId = $serviceID . '0000' . $paymentID;
    ...
    $this->orderTransactionRepository->create([
        'transaction_id' => $transactionId,
        ...
        'status'         => 'pending',
        'data'           => json_encode(['paidAmount' => $amount]),
    ]);
}

Note the transaction id: a service id, four zeroes, and a payment id, concatenated. It is not a transaction identifier from anybody — it is a string I built in the hope that it would match whatever the gateway would eventually send back. And the row is written at the moment the customer is redirected, before any money has moved. Abandon the payment page and the record stays forever, describing a payment that never happened, with a paidAmount equal to what you would have paid.

The pull request titled Fix EGHL still making pending deletes that method entirely. The other one, Transaction history should record paid only, closes the same hole from the callback side:

+if ($status === OrderTransaction::STATUS_PAID) {
     if ($existingTransaction) {
         $existingTransaction->update([...]);
     } elseif ($transactionData["transaction_id"]) {
         $this->orderTransactionRepository->create($transactionData);
     }
+}

Before, every callback of any status wrote a row. After, only a paid one does. Together, those two changes mean the transaction table finally answers exactly one question — did money arrive — instead of also containing intentions, failures and abandonments that looked identical to payments at a glance.

This is the same job as the very first article in this series, which was about a product page advertising discounts that did not exist. Different table, same problem: a number was being stored before it was true.

The same pull request also dispatches a new event when a paid transaction is saved, so other parts of the system can react to money arriving rather than polling for it. And it deleted a comment I would have kept — // transactions need to be loaded to properly calculate outstanding, sitting above an eager load whose necessity is not obvious from the line itself. The eager load stayed; the reason it exists did not.

The rest of the first: an asset build tweak for low-end devices, the word "Flash" removed from a mobile flash-sale badge (twice, in two separate pull requests, same title), the outstanding amount going back to the total after a refund, a bulk-update bug where different SKUs got the same file, and unused variables in the payment webhook.

Eight hundred and ten lines of reporting, deleted

Also on 1 July: Removed & cleanup dashboard SLA, which is 810 deletions and no additions.

0+ 293-  admin/reporting/view.blade.php
0+ 179-  src/Admin/Helpers/Reporting/Sale.php
0+ 175-  admin/reporting/sales/index.blade.php
0+ 121-  src/Admin/Helpers/Reporting.php
0+  21-  Http/Controllers/Admin/Reporting/SaleController.php
0+  20-  src/Routes/Admin/reporting-routes.php

An entire sales reporting module: routes, controller, two helper layers and two views, gone. I did not write a replacement, which tells you it was not being used — or that whatever it reported was available somewhere better.

I have come to think the deletions are the most honest measure of a codebase's health, and July has a lot of them. This one, the pending-transaction method, 345 lines of API controller later in the month, 88 lines of lazy-loading script, 24 lines of a checkout price breakdown. A shop that only ever grows is a shop where nobody has the standing to say a thing was a mistake.

The currency stopped being a string

Removed hardcoded MY currency and use bagisto config — 62 additions, 43 deletions, twenty-four files.

Twenty-four files is the interesting number. RM and MYR were written into a price formatter, the product page, the checkout confirmation, the XOX telco sidebar, the cart controller, a shared JavaScript bundle, the product feed, and the Meta Pixel event controller. A currency is the kind of fact that feels permanent when a shop exists in one country, so it gets typed wherever it is needed, and eight of those places are outside anything you would call the pricing layer.

June's article was about a design for a second country that closed without merging. This is what actually survived from it, a month later: not the architecture, just the removal of the assumption that made the architecture impossible. That is usually how it goes. The big design document is the thing you throw away and the twenty-four-file find-and-replace is the thing that ships.

A revert, an un-revert, and a re-revert

On 21 July two pull requests merged overnight. By lunchtime both had been reverted in the same minute. What followed is the strangest sequence in this series:

21 Jul 00:29   2051: UI Clean Up                              +90/-16   merged
21 Jul 02:27   2083: LCP Improvement                        +608/-637   merged
21 Jul 11:52   Revert "2083: LCP Improvement"
21 Jul 11:52   Revert "2051: UI Clean Up"
21 Jul 12:34   Revert "Revert "2051: UI Clean Up""
24 Jul 04:20   Revert "Revert "Revert "2051: UI Clean Up"""
30 Jul 07:19   2051: UI Clean Up (1&3)                        +32/-10   merged

Read as an incident, it is legible: something broke in the morning, both overnight changes went out together because nobody knew which one it was, one of them was cleared forty-two minutes later and put back, and three days later it turned out to be that one after all. Then a week after that, a third of it shipped. I cannot prove that reading from the repository — none of the four reverts has a description, which is a habit I have complained about in this series for two years and am still guilty of. But it is what the timestamps support.

What I can prove is that UI Clean Up was not a cleanup. Here is its centre:

-  {{ Option::formatPrice($price) }}
+  {{ Option::formatPrice($priceAfterVoucher) }}

-  {{ trans('web.saved', ['amount' => Option::formatPrice($saving)]) }}
+  {{ trans('web.saved', ['amount' => Option::formatPrice($savingAfterVoucher)]) }}

It changes which price the product page shows. The headline number, the saving, the sticky bar and the total all switch from the price to the price after an automatically applied delivery voucher — a lower, more attractive figure, and one the customer really does pay when the voucher applies to them.

And it had a second half, on the listing pages:

-  @define $minPrice = $product->min_price;
-  @define $regularPrice = $product->regular_price;
+  @define $minPrice = $product->available_min_price;
+  @define $regularPrice = $product->available_regular_price;

Backed by fifty-one new lines on the product model. That is the opposite kind of change: a "from RM X" on the home carousel computed from variants you can actually buy, rather than from any variant that exists including the sold-out cheap one.

The version that finally shipped on 30 July kept the product page changes and dropped every one of those listing changes — the model accessors, the carousel, the category listing. So the half that made the advertised price more honest is the half that did not survive, and if the failure was what I think it was, the cause is the fifty-one lines of new accessors running on listing pages. This shop's recurring injury for three years has been a computed property that is cheap on one product and ruinous across forty.

Promotions moved back into the page

The other overnight change, LCP Improvement, is the one that stayed reverted, and it is worth describing because it undoes a decision I made deliberately.

+476   src/Cart/PromotionFrameResolver.php
-345   Http/Controllers/Api/PromotionController.php
- 88   web/scripts/card-promotion-lazy.blade.php
- 48   Http/Controllers/Api/FlashSaleController.php
-  5   src/Routes/api.php
- 24   checkout/partials/price-breakdown.blade.php

Promotion frames — the borders, icons and badges around a product card — were being fetched by the browser after the page painted, from two API endpoints, by a lazy script. That is a reasonable design if you want the HTML to be identical for everyone and cacheable. It is also, precisely, a Largest Contentful Paint problem: the biggest visual element on the page arrives in a second round trip.

So this replaces all of it with a resolver that computes the frame during render, and deletes the endpoints. The trade is that the page is now more expensive to build and no longer identical for everyone — but the shop has served pages from a ten-minute edge cache since September 2025, and work done inside a cached render is amortised over every hit. Once you have a cache in front of you, moving work into the render is nearly free and moving it into the client is nearly always wrong.

I still believe that. It was reverted anyway, and it has not come back.

An API key, an export, and then a different export

Two pull requests nine days apart, both about letting another system read this one.

The first, titled Hourly Stock Sync, is a small subsystem: an api_settings table and migration, an admin screen to manage keys, a ProductApiKey middleware, a product export controller, and a console command to run on a schedule.

The second, Add N8N api listener on bagisto, deletes that export controller and replaces it with a 118-line stock controller, and removes five lines from the application bootstrap.

Nine days between building an export and replacing it with a different one. The name in the second title says why: the consumer turned out to be n8n, a workflow automation tool, and what n8n wanted was not a product export on a schedule but a stock endpoint it could call. Building the general thing first and the specific thing second cost nine days and about 140 lines, which is cheap, and it is the same shape as the AI settings on this website being three text fields because I tried five providers before deciding.

Worth noting what the middleware means: this shop now has an authenticated machine-readable surface with its own key management, separate from the admin login. That is a different security posture than a storefront, and it arrived in a pull request called Hourly Stock Sync.

One thing that entered version control, and one thing that should not have

On 6 July I committed 994 lines that belong in a repository: the warranty portal's Supabase project, as a 544-line initial schema migration and a 414-line configuration file, with documentation.

That portal has appeared in these articles since 2025 as a thing that exists somewhere. In April it started capturing customer consent. Until 6 July its schema lived only in a hosted dashboard, which means the answer to "what shape is that database" was "log in and look", and the answer to "who changed it and when" was nobody knows. Now it is a file with a timestamp in its name. This is the same lesson as the README I wrote on day one of this website, and it took until month thirty-two to apply it to a database that holds customers' warranty claims.

The same month contains the opposite. Two of July's infrastructure pull requests add material to the repository that should never be in one, and I am not going to be more specific than that here. The repository is private, but a private repository is an access-control boundary, not a secret store, and describing exactly what and where in a public article would be handing someone a map. It has been done this way since the project's first commit in December 2023, so it is a standing practice rather than a July mistake — July just renewed it.

The fix is the boring one: rotate, move the material into a secret manager the deployment reads at boot, and treat the history as compromised rather than assume nobody looked. I have written it down here without the details because a retrospective that quietly skips the uncomfortable item is not a retrospective, and one that publishes a live weakness in full is not responsible either. That is the same line I drew around December's Bagisto disclosure and April's licensing project.

Vouchers, for the ninth time

Delivery voucher not auto-removed when customer switches to Reservation — 231 additions against 331 deletions, mostly in the order controller and the cart controller. A customer picks delivery, gets a delivery voucher applied automatically, then switches to reservation, and the voucher stays — discounting a delivery they are no longer having.

That is the ninth month in this series with a voucher bug. October 2024: a hand-rolled condition evaluator. December 2024: an operator comparing the wrong way round. April 2025: a revert. July 2025: a cascade in the XOX mechanism. December 2025: a helper to stop the duplication. June 2026: a first() call that only became wrong once the feature was used as designed. July 2026: a voucher that outlives the thing it discounts.

The through-line, nine instances in, is that this shop's discounts are conditional on cart state and nothing owns the job of re-deriving them when that state changes. Each fix has been a listener or a guard for one transition. The net negative diff here is at least the right direction — a hundred fewer lines doing a more correct thing.

Also in July's voucher work: stackable vouchers appearing on recommended products, where they should not. And on webhooks, a new per-webhook option:

ALTER TABLE webhooks ADD COLUMN include_failed_orders ...

By default, a webhook now fires only for orders that were actually paid; a checkbox opts a specific integration into hearing about unpaid and cancelled ones too. That is the same instinct as the transaction table on 1 July, expressed as a configuration flag: the systems downstream had been receiving events for orders that never became sales, and dealing with it themselves, badly.

And a nine-line addition to CLAUDE.md in the company repository, titled Adding graphify — a personal AI tool convention, checked into the day job, a month after I put an AI reviewer on every pull request there. The tooling arrives before anyone has decided on a policy for it. That is worth noticing while it is still small.

Twelve days

Now the other half of the month.

The Discord bot did nothing between 1 and 19 July. On 20 July it opened a pull request of 7,669 additions across eighty-three files, and by the 31st it had produced 186 commits, nine merged pull requests and four new subsystems: a market, a casino, a daily-task engine and a voice.

June's article ended with the bot's experience points becoming a currency with five verbs. July is what happens when a currency exists and you keep going.

XP became money

The pull request titled Adding stock & vault system is a cryptocurrency market inside a Discord bot. Real prices, real coins, in Indonesian rupiah:

const COINGECKO_BASE = 'https://api.coingecko.com/api/v3';
const POLL_INTERVAL_MS = 60_000;
const UNIVERSE_PER_PAGE = 250;
const UNIVERSE_PAGES = 4;
const MOVERS_COUNT = 50;
export const MIN_BUY_XP = 1000;

A thousand coins polled every sixty seconds, a fifty-row board, top fifty gainers and losers, and a search. Members buy positions with experience points, minimum a thousand XP:

const spendable = await getSpendableXp(memberId, guild_id);
if (spendable.total < amount) return { ok: false, error: 'insufficient_xp' };
...
const avgPrice = newInvested > 0
    ? (prevInvested * prevPrice + amount * market.price) / newInvested
    : market.price;

Topping up a position takes a weighted average of your entry prices, which is the correct thing and not the obvious thing. Selling pays out proportionally to how the price moved:

const currentValue = buyPrice > 0 ? Math.round(invested * (market.price / buyPrice)) : invested;
...
await db.updateMemberLevelStats(memberId, { experienceIncrement: payout });

And there is the sentence that matters. The payout is added to experience. Experience determines your level, and level determines your position on the server's leaderboard. So a member who buys a coin that goes up outranks a member who talked to people.

Everything else about this is careful. Prices cache in Redis with a five-minute time-to-live. The poller takes a Redis lock with NX so exactly one process polls no matter how many are running — the lesson from a year of in-process Set locks that did not survive a restart, finally applied at the point of writing rather than after a bug. Coins held by members but outside the top thousand get their prices fetched individually so a portfolio never goes stale. The price column is DECIMAL(30, 12), because a rupiah price of Bitcoin needs the digits on the left and a joke token needs them on the right. Positions can be sold partially. Every buy and sell writes an event row with a signed net.

That is a competently built trading system. My hesitation is not about the engineering.

A gamble with no house edge

June's shop had a gamble verb, which I described at the time as a wagering mechanic shipped to a young audience as the third item in a list of five with no reasoning recorded anywhere. July promoted it to a subsystem with its own table, its own page and its own name.

const MIN_MULTIPLIER = 1.01;
const MAX_MULTIPLIER = 10;
const MIN_WAGER = 1;
const ANNOUNCE_DELAY_MS = 7000;

function winChanceFor(multiplier: number): number {
    return 100 / multiplier;
}

You pick a multiplier between 1.01 and 10, and your chance of winning is a hundred divided by it. Work out the expectation: at a multiplier of m you win m times your stake with probability 1/m, so the expected return is exactly your stake. There is no house edge. Nothing is skimmed, the economy is not drained, and over a long enough run a player breaks even.

I want to give that its due, because it is a deliberate choice and the opposite choice was easier. It is also not the same as harmless. A zero-edge game with a ten-times multiplier is a variance machine: it does not take XP out of the system, it moves XP from the unlucky to the lucky, and the thing XP buys is standing in a community. And the roll is Math.random() — not manipulated, and also not verifiable by anyone. There is no seed a player can check. The fairness of this game rests entirely on trusting the person who wrote it, which is me.

Then this:

embed.setTitle('🎲 Minigame Lost')
     .setDescription(`${actorMention} wagered ${fmtXp(result.wager)}${multNote} and **lost it all**.`)
...
await channel.send({ content: actor, embeds: [embed] });

Seven seconds after the roll, win or lose, the result is announced in a configured channel with the member tagged. Wins are celebrated and losses are published by name. That is a design decision about engagement, and it works, and what it means in practice is that a fourteen-year-old losing a week of accumulated XP gets a notification about it in front of the server.

The migration that created the table is its own small confession:

INSERT INTO server_member_minigame_logs (member_id, game, multiplier, wager, payout, ...)
SELECT l.member_id, 'gamble', 2, ...
FROM server_member_item_logs l WHERE l.action = 'gamble' ...;

DELETE FROM server_member_item_logs WHERE action = 'gamble';

June's gamble rows are copied into the new table with a multiplier of 2 hardcoded, because the old schema never recorded one, and then the originals are deleted. Every historical gamble in this bot now claims to have been played at two times. It is a reasonable guess — two was almost certainly the only option — and it is still a number the system invented and then stored as fact. Given that the article this series opened with was about a shop displaying a saving it had not calculated, I should hold my own database to the same standard.

What the code does have: a per-server switch. isPublicSubFeatureEnabled(guild_id, 'minigames') is checked before any wager, so a server owner who wants a levelling bot without a casino has one. That switch is the thing I would point to if someone asked me to defend this, and it is doing real work.

Luck is a percentage point

The item system I described in June as five verbs is, by late July, ten effect types: boost, leech, shield, reflect, insurance, disguise, steal, bomb, gift and now luck. There are bounties. There is bag capacity. There are cooldowns, immunity windows and a disguised-mention helper so an attacker's name is hidden.

Luck is implemented as flat percentage-point arithmetic applied to whatever it touches:

function applyLuckBoost(value: number, luckPercent: number, max = 100): number {
    if (luckPercent <= 0) return value;
    return Math.min(max, value + luckPercent);
}

function applyLuckReduction(value: number, luckPercent: number): number {
    if (luckPercent <= 0) return value;
    return Math.max(0, value - luckPercent);
}

It adds to the percentage you skim when you leech someone, adds to your insurance refund, subtracts from a leech aimed at you, and subtracts from the tax on a gift.

Look at what that means numerically. A default leech skims ten percent; twenty points of luck makes it thirty — you have tripled it. A default insurance refund is a hundred percent; twenty points of luck makes it a hundred, because it is already at the cap — you have done nothing. The same item is a triple-strength effect on one stat and a no-op on another, and nothing in the code or the item description says so. That is a balance bug hiding as an implementation detail, and it is visible in six lines.

The part I keep circling is that these are all player-versus-player. Steal, bomb, leech, bounty, disguise. A levelling bot that started by counting messages now has a mechanic for taking a percentage of someone else's earnings while hiding your name, and a shop that sells it.

Nine slots, seventy-three metrics, eight of them gambling

The task system is the largest single file the bot has: 1,733 lines defining daily and weekly objectives.

export const DAILY_TASK_SLOTS = 9;
export const WEEKLY_TASK_SLOTS = 9;
export const STREAK_FREEZE_MAX = 2;
export const STREAK_FREEZE_EARN_EVERY = 10;
export const LOGIN_CYCLE_DAYS = 7;

Nine daily tasks, nine weekly, three difficulties, rewards paid in XP or items. A streak that breaks if you miss a day, with two "freezes" you can bank and one more earned every ten days. A seven-day login claim cycle. Day boundaries computed per member from a stored timezone offset, so "today" means today where they live.

The generator is the part I am proud of:

const rand = mulberry32(hashSeed(period, memberId, serverId, periodKey, slot));
let candidates = pool.filter((d) => d.difficulties.includes(wanted) && !used.has(d.id) && isViableFor(d, wanted, elig, period));

Tasks are not rolled and stored, they are derived from a seeded generator keyed on member, server, period and slot. The same member on the same day always gets the same nine tasks, on any machine, without persisting the choice first. And the pool is filtered by what the server has switched on, so a server with minigames disabled never generates a gambling task.

Rewards scale to the shop rather than to a constant:

function scaledBase(medianCost: number, factor: number): number {
    const linear = median * factor;
    if (linear <= 12000) return Math.max(XP_REWARD_MIN, Math.round(linear));
    return Math.max(XP_REWARD_MIN, Math.round(12000 + Math.sqrt(linear - 12000) * 90));
}

A task pays about one and a half times the median item price, damped by a square root above twelve thousand so that an inflating economy does not turn daily tasks into a fountain. There is a streak bonus of two percent per day, capped at double. Whoever inherits this will find the constants tuned and the reasoning absent, which is the same complaint I have been making about my own commit messages for two years.

Now the number I cannot write around. The task metrics are an enumeration of seventy-three things the bot can ask you to do. Six of them are conversation and voice. Most of the rest are the item economy. And these are in the list:

| 'gamble_played'    | 'gamble_won'       | 'gamble_wagered'
| 'gamble_highroll'  | 'gamble_bigwin'    | 'gamble_purist'
| 'gamble_lucky'     | 'gamble_lucky_win'

Eight of the seventy-three task metrics are about wagering. That changes the nature of the thing. A casino you can walk past is one design; a daily objective that pays you for wagering, with a streak you lose if you skip a day, is a different one. I did not sit down and decide to build a retention loop that rewards gambling. I built a task system, and gambling was one of the mechanics available to make tasks out of, so it became eight of them, and the streak was there to make people come back.

Two further pull requests in the following days are titled Improving task algo and Improve task algo and daily claim, which tells you the first version's numbers were wrong. The second of them carries a migration file named 20260735_standardize_xp_columns.sql — the thirty-fifth of July, a date that does not exist, which is what happens when you hand-type a filename at that pace.

The bot learned to talk

On 31 July, the last day of the month, the bot got a voice. Not text-to-speech on a message — a live conversation in a Discord voice channel.

The text side is a chat model reachable when the bot is mentioned or replied to, with a ten-message rolling history per member stored in the database, a per-user in-flight guard, and two tools:

name: 'join_voice',
description: 'Join the voice channel the user is currently in, so you can talk with them out loud.
              Only call this when the user asks you to join voice, join the call, or talk to them.'

The join is not performed in place. It publishes a command to a separate voice worker and reads back a shared state, so the gateway process and the audio process are different things that coordinate through Redis, and voice_worker_unavailable is a real answer the model can receive and explain. The tool loop is bounded to five rounds, which is the fix I wrote in April after a model called the same tool until it ran out of context, applied this time before the bug.

The audio path is the most low-level code in either of my projects:

const INPUT_RATE = 16000;
const OUTPUT_RATE = 24000;
const DISCORD_RATE = 48000;
const FRAME_MS = 20;
const FRAME_BYTES = (DISCORD_RATE / 1000) * FRAME_MS * 2 * 2;

Discord speaks 48 kHz stereo in twenty-millisecond frames of 3,840 bytes. The model listens at 16 kHz mono and answers at 24 kHz. So there is a hand-written converter in the middle:

const srcIndex = Math.floor((i * fromRate) / toRate);
let sample = 0;
for (let c = 0; c < fromChannels; c++) { ... sample += pcm.readInt16LE(offset); }
sample = Math.max(-32768, Math.min(32767, Math.round(sample / fromChannels)));

That is nearest-neighbour resampling with channel averaging and no filter, which will alias — downsampling 48 kHz to 16 kHz by picking every third sample folds anything above 8 kHz back into the audible band as distortion. A resampling library would do better. It is thirty lines, it works well enough for speech, and I am recording the compromise rather than implying I did the correct thing.

Around it: barge-in handling with a 400 ms speaking guard and a 700 ms silence threshold to close a turn, a playback queue capped at sixty chunks with a dropped-chunk counter, a session resumption handle for reconnects, a heartbeat that refreshes the shared voice state before its TTL expires, and eleven counters of frames, bytes, interrupts and reconnects. Somebody had to debug real-time audio, and that somebody left instruments behind.

Then the cost control, which is my favourite thing in the month:

const IDLE_TIMEOUT_MS = 60_000;
const SESSION_MAX_MS = 15 * 60_000;

const TIMEUP_GOODBYE_PROMPT =
  "Your time in this call is up. Say a short, natural goodbye out loud — you have to go,
   you're busy, you'll talk later. One sentence. Do not explain why.";

A minute of silence, or fifteen minutes total, and the session ends. But it does not hang up. It asks the model to make an excuse and leave politely, and instructs it not to explain the real reason. I wrote a prompt telling a machine to be vague about why it is going, so that a budget limit would feel like a person having somewhere to be. I think it is the right product decision and I want it written down that it is also, precisely, a small instructed deception.

The provider handling is March's problem grown up. Thirty selectable voices with tone labels, and this:

if (modelLower.includes('glm'))      return { chat_template_kwargs: { enable_thinking: true, clear_thinking: false } };
if (modelLower.includes('nemotron')) return { chat_template_kwargs: { enable_thinking: true }, reasoning_budget: -1 };
if (modelLower.includes('qwen'))     return { chat_template_kwargs: { enable_thinking: true } };
if (modelLower.includes('deepseek') || modelLower.includes('kimi')) return { chat_template_kwargs: { thinking: true } };

Five model families, five different ways to ask for reasoning, detected by looking for substrings in a model name. It works and it is a guess: a model served under a renamed identifier silently gets no reasoning at all and nothing reports it. There is also a stripReasoning that removes <think> and <reasoning> blocks from replies — the third codebase I have now written that function in.

Two things that did not ship

Base games, opened 23 July, closed unmerged: 3,424 additions against 6,691 deletions across eighty files, including 4,474 lines removed from the stylesheet and every public page rewritten. A visual foundation rebuilt from scratch and then abandoned. That is the second large closed pull request on this project in four months, after April's analytics dashboard.

And the release itself had a cost. Fix after major release issues, the day after the 7,669-line market landed, is 847 additions against 1,179 deletions — the two big new public pages largely rewritten within twenty-four hours of shipping. When one person merges eighty-three files with no reviewer, the review happens in production the next morning.

What July was

At the day job: a transaction table that stopped recording payments that had not happened, a sales report deleted rather than maintained, a currency removed from twenty-four files, an authenticated machine surface with its own keys, a warranty database's schema put under version control eight months late, a ninth voucher bug, and a revert sequence three levels deep that ended with a third of the original change.

On the bot: a crypto market, a zero-edge casino, ten player-versus-player item effects, eighteen generated objectives a week with a streak, and a live voice that makes an excuse when its budget runs out. Twelve days, 186 commits, one reviewer.

The two halves are the same story from opposite ends, and I only saw it while writing this. At work, every good thing I did in July was subtraction — a pending transaction that was not a payment, a report nobody read, a promotion fetched in a second request, a hardcoded currency, a voucher for a delivery that was cancelled. The month's value was in making the numbers mean what they claim.

On my own project I spent the same weeks adding four new ways to earn the one number that matters there. Experience points used to mean "this person participates here". By the 31st of July they also mean market timing, dice, successful theft, and having logged in nine days in a row. Each mechanism is defensible, each one is well built, and together they have made the leaderboard measure something I could not define in a sentence.

If I take one thing forward it is that I know how to notice this at work and not at home. The shop got an article's worth of scrutiny about whether a displayed price was true. The bot got four subsystems in twelve days and the only design document is the schema. Next time the bot grows an economy, the thing to write first is what the number is supposed to mean.