<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Postgresql on hippotion</title><link>https://blog.hippotion.com/tags/postgresql/</link><description>Recent content in Postgresql on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 02 Jan 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/postgresql/index.xml" rel="self" type="application/rss+xml"/><item><title>🫙 I Built a Tracker for My Kombucha. The Data Model Was the Hard Part.</title><link>https://blog.hippotion.com/posts/kombucha-tracker/</link><pubDate>Fri, 02 Jan 2026 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/kombucha-tracker/</guid><description>Brewing kombucha looks simple until you try to model it: one batch splits into many flavored bottles, every jar generates a stream of pH and taste readings, and a SCOBY has a lineage. Here&amp;rsquo;s the little app I built to keep track — and why the schema, not the code, was the real work.</description><content:encoded><![CDATA[<h2 id="i-brew-kombucha">I brew kombucha</h2>
<p>If you haven&rsquo;t fallen down this hole: kombucha is sweet tea fermented by a SCOBY (a rubbery pancake of yeast and bacteria) into something tart and fizzy. It&rsquo;s a <em>living</em> hobby — the culture is alive, every batch is a little different, and the only way to get good is to pay attention and remember what you did.</p>
<p>I was not remembering what I did. Brew dates lived in my head, taste notes lived nowhere, and &ldquo;which jar was the ginger one again?&rdquo; was a genuine question I asked myself out loud, to a fridge.</p>
<p>So I built a tracker. It&rsquo;s called <strong>HipPotion</strong> — same family as everything else I run here. The brewing turned out to be the easy part. Modeling it was where it got interesting.</p>
<h2 id="why-a-simple-list-doesnt-fit">Why a simple list doesn&rsquo;t fit</h2>
<p>My first instinct was &ldquo;a batch is a row, log some notes.&rdquo; That falls apart fast, because kombucha isn&rsquo;t linear. It has two stages:</p>
<ul>
<li><strong>F1 (first ferment):</strong> the big jar of sweet tea + SCOBY, fermenting sour over a week or two. One vessel, one culture.</li>
<li><strong>F2 (second ferment):</strong> you split that sour base into bottles and flavor each one differently — ginger in this one, blackberry in that one, hibiscus in the next — then seal them to build carbonation.</li>
</ul>
<p>So <strong>one batch becomes many bottles, each with its own flavor, its own carbonation, its own outcome.</strong> A flat &ldquo;batch = row&rdquo; model can&rsquo;t express that. And on top of the branching, every jar and bottle produces a <em>stream</em> of observations over time: pH today, Brix tomorrow, &ldquo;tastes too sweet still&rdquo; the day after.</p>
<p>That&rsquo;s three different shapes at once — a lifecycle, a one-to-many split, and a time series — for what looks from the outside like &ldquo;I made some tea.&rdquo;</p>
<h2 id="the-model-i-landed-on">The model I landed on</h2>
<p>Six tables, each earning its place:</p>
<ul>
<li><strong><code>recipes</code></strong> — the templates. Tea blend, sugar ratio, target numbers. A batch points at one.</li>
<li><strong><code>batches</code></strong> — an actual F1 brew, with a lifecycle (<code>planned → active → conditioning → finished</code>) and a reference to its recipe.</li>
<li><strong><code>fermentation_log_entries</code></strong> — the time series. One row per observation per batch: pH, Brix, temperature, taste/smell notes, what I did. This is where the &ldquo;pay attention and remember&rdquo; lives.</li>
<li><strong><code>f2_variant_batches</code></strong> — the branch. Each is a flavored bottle split off a parent batch, tracked on its own.</li>
<li><strong><code>starter_log</code></strong> — SCOBY lineage. Cultures have parents; you grow new ones from old ones, and a sick culture ruins a batch, so the lineage matters.</li>
<li><strong><code>botanical_infusions</code></strong> — the flavoring ingredients, managed per recipe.</li>
</ul>
<p>The shape that took the longest to get right was the <strong>F1 → F2 split</strong>: a variant has to belong to its parent batch but live its own life. Once that relationship was clean, the whole thing clicked — the app finally matched how brewing <em>actually works</em> instead of how it&rsquo;s easy to store.</p>
<h2 id="the-stack-and-where-it-runs">The stack (and where it runs)</h2>
<p>Nothing exotic: React + Vite + TypeScript on the front (TanStack Query, shadcn/ui, Tailwind), a <a href="https://hono.dev">Hono</a> + Drizzle ORM API on the back, PostgreSQL underneath. Built with AI coding tools — I leaned on them hard for the React/shadcn front-end, less so for the schema, which I argued out by hand because it&rsquo;s the part that had to be <em>right</em>.</p>
<p>It runs on my k3s homelab like everything else: a Helm chart deploys the nginx frontend, the Hono API, and a Postgres StatefulSet, all reconciled by Argo CD from Git. Default-deny networking, secrets out of Git — the <a href="/posts/homelab-gitops/">usual platform defaults</a>. It&rsquo;s a hobby app, but it gets treated like a real one, because the platform doesn&rsquo;t know the difference and I don&rsquo;t want it to.</p>
<h2 id="it-became-an-api-for-something-else">It became an API for something else</h2>
<p>The unexpected payoff: because the data model was clean and the API was just a set of plain REST endpoints, it made a perfect target for an experiment. I later <a href="/posts/n8n-agent-cloud-vs-local/">pointed an AI agent at it from n8n</a> — &ldquo;what&rsquo;s fermenting right now?&rdquo;, &ldquo;log that this batch tastes tart&rdquo; — and the agent just called the same endpoints the UI does. A good schema is reusable in ways you don&rsquo;t plan for. The kombucha tracker quietly became a little knowledge base I can talk to.</p>
<h2 id="honest-notes">Honest notes</h2>
<p>This is a personal hobby app for an audience of one (me). It&rsquo;s AI-assisted, it has no tests, and the UI has rough edges. I&rsquo;m not pretending it&rsquo;s a product.</p>
<p>But the thing I keep coming back to: the hard, valuable part wasn&rsquo;t the framework or the deployment — it was sitting with a messy real-world process long enough to find the <em>shape</em> of it. The branching ferment, the time series, the lineage. Get the model honest and the rest is just typing. Get it wrong and no amount of nice UI saves you.</p>
<p>Also, the kombucha&rsquo;s been better since I started writing things down. Turns out the fridge wasn&rsquo;t a great database.</p>
]]></content:encoded></item><item><title>📊 I Added a Stats Service to My Game to Answer One Question. It Multiplied.</title><link>https://blog.hippotion.com/posts/dice-and-shrines-stats/</link><pubDate>Fri, 18 Jul 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/dice-and-shrines-stats/</guid><description>Building a telemetry backend for Dice &amp;amp; Shrines — every attack logged, every guardian tracked, every die rolled accounted for. What the data revealed about balance, luck, and how people actually play.</description><content:encoded><![CDATA[<h2 id="the-problem">The problem</h2>
<p>I built <a href="/posts/dice-and-shrines">Dice &amp; Shrines</a> with five asymmetric guardian characters. Each one has a different passive and active ability that changes how reinforcements distribute, which territories you can attack, and what happens when you take damage.</p>
<p>The question I couldn&rsquo;t answer from just playing was: <strong>are they actually balanced?</strong></p>
<p>Not &ldquo;do they feel different&rdquo; — they obviously do. But is Fox&rsquo;s stored critical actually overpowered? Is Turtle&rsquo;s loss-recovery passive strong enough to matter, or is it just flavour? Is there a first-mover advantage baked into the map structure?</p>
<p>You can&rsquo;t answer questions like these from vibes. You need data. So I built a stats service.</p>
<hr>
<h2 id="what-gets-recorded">What gets recorded</h2>
<p>Every game produces five event types, posted as fire-and-forget HTTP calls from the game client to <code>game-stats.hippotion.com/event</code>:</p>
<p><strong><code>map_generated</code></strong> — logged when the map generator accepts a map. Records territory count, average territory size, minimum size, and how many generation attempts it took. This tells me how often the generator discards its own work and whether the acceptance criteria are too strict.</p>
<p><strong><code>game_start</code></strong> — fired when a game begins. Captures the number of players, the guardian assigned to each slot, and which slot is human. Returns a <code>gameId</code> that travels with the game for the rest of its life.</p>
<p><strong><code>attack</code></strong> — fired on every single dice roll. Attacker, defender, from-territory, to-territory, how many dice each side had, what they rolled, who won. This is the raw material for the probability analysis.</p>
<p><strong><code>elimination</code></strong> — fired when a player is knocked out. Records which guardian they were and how many players remained, so I can tell who exits first and who makes the final stand.</p>
<p><strong><code>game_end</code></strong> — fired on win or abandon. Records the winner&rsquo;s guardian, how many turns the game took, and whether it was abandoned.</p>
<p>The service is a FastAPI app backed by PostgreSQL, running in the homelab on the same k3s cluster as the game. About 150 lines of Python plus a schema.sql that the app runs on startup.</p>
<hr>
<h2 id="the-dashboard">The dashboard</h2>
<p>The stats dashboard is a single-page HTML response from <code>/</code> — self-contained, no external framework, chart.js for the visualisations. It polls <code>/api/stats</code> every 30 seconds and updates in place.</p>
<p>What it shows:</p>
<p><strong>Overview cards</strong>: total games, games today, games this week, human win rate, average turns per game, overall attack win rate, abandoned game count.</p>
<p><strong>Activity charts</strong>: games per day (last 7 days), game duration distribution in 10-turn buckets.</p>
<p><strong>Death spiral analysis</strong>: when players abandon (broken into phases: instant, early, mid-early, mid, late), and first-mover advantage — win percentage by player slot 0 through 5.</p>
<p><strong>Attack behaviour</strong>: the dice margin chart is the most interesting one. It shows attack volume and win rate for every possible attacker-dice-minus-defender-dice value, from strongly negative (attacker is outmatched) to strongly positive. Overlaid: a win rate line. You can see the actual probability curve emerging from real games and compare it to what the math predicts.</p>
<p><strong>Guardian intelligence</strong>: win rate, pick count, average attacks per game, survival rate to turn 50+, and average turns per winning game — per guardian, human players only.</p>
<p><strong>Elimination intelligence</strong>: when the first player gets knocked out per game, and a guardian fate table showing average elimination order and first-out percentage. Earliest-exiting guardian is surfaced explicitly.</p>
<p><strong>Map influence</strong>: territory count versus average game length. Also an attack efficiency heatmap — win rate for every attacker-dice × defender-dice combination, 1 through 8, rendered as a colour grid.</p>
<p><img alt="Attack efficiency heatmap — win rate for every attacker × defender dice combination" loading="lazy" src="/posts/dice-and-shrines-stats/stats-heatmap.png"></p>
<p><strong>Recent games</strong>: last 15 games with the human player&rsquo;s guardian, result, and IP address so I can tell if it&rsquo;s me testing or an actual player who wandered in.</p>
<hr>
<h2 id="what-the-data-showed">What the data showed</h2>
<p>The attack win rate across all games sits just under 60%. That&rsquo;s higher than a naive analysis suggests it should be — if both sides roll fairly, equal dice should be near-even. The explanation is selection bias: players only attack when they have a dice advantage. Nobody sends 2 dice at 8 dice repeatedly. The average attack has a positive margin, so the average win rate is above 50%.</p>
<p>The margin chart made this explicit. The plurality of attacks have a margin of +2 or more. The sub-zero margin attacks — technically losing plays — are a real but small fraction, usually late-game desperation or deliberate tempo plays.</p>
<p><img alt="Attack risk profile: dice margin distribution with win rate overlay — real data from the dashboard" loading="lazy" src="/posts/dice-and-shrines-stats/stats-margin.png"></p>
<p><strong>Human vs AI attack quality</strong> turned out to be the sharpest comparison. Humans and AI have different average margins. The AI is greedy but disciplined about attack selection; humans sometimes take gambles the AI wouldn&rsquo;t. You can see it in the numbers.</p>
<p><strong>First-mover advantage</strong> is measurable but not massive. Player slot 0 (goes first) has a slightly higher win rate than the average. Slots at the higher end of turn order are somewhat depressed. Not broken, but real — and a useful thing to watch if I ever add a competitive mode.</p>
<p><strong>Guardian balance</strong>: the win rate gap between the best and worst guardian tells me whether the balance is within acceptable range or a concern. The dashboard calls it out explicitly: if the gap exceeds 15 percentage points, it flags it as a balance issue. That threshold is arbitrary, but it forces a decision rather than letting drift accumulate unnoticed.</p>
<p><img alt="Guardian stats for human players — the 27.7pp win rate gap between Hippo and Fox, flagged as a balance concern" loading="lazy" src="/posts/dice-and-shrines-stats/stats-guardians.png"></p>
<p><strong>Abandonment phases</strong>: most abandonments are instant — the player clicked &ldquo;new game&rdquo; before actually playing. The interesting number is mid-game abandonment, which is a proxy for death spirals: you see your income drop, you know you&rsquo;re losing, you close the tab. That&rsquo;s a design signal, not just a metric.</p>
<hr>
<h2 id="designing-for-measurement">Designing for measurement</h2>
<p>The useful insight from building this is that it changes how you design the game. Once you know every attack is being logged, you start thinking about what the attack data will tell you. Shrines give territories a guaranteed die — does that show up in attack margins near shrine territories? I didn&rsquo;t add territory-topology tracking, but I could. The schema is just a few columns away.</p>
<p>The same goes for guardian abilities. Fox&rsquo;s stored critical fires at turn boundaries — I log turn number on every attack, so I can look for Fox spikes in attack win rate on certain turns. I haven&rsquo;t run that query yet, but the data is there if the balance question becomes sharp enough to need it.</p>
<p>That&rsquo;s the thing about adding observability to something you built yourself: you stop guessing about whether it&rsquo;s working and start reading the evidence. The game got more interesting to design once I could see what was actually happening inside it.</p>
<hr>
<h2 id="the-stack">The stack</h2>
<ul>
<li><strong>FastAPI</strong> — event intake and stats API, ~150 lines</li>
<li><strong>PostgreSQL</strong> — five tables: maps, games, game_guardians, attacks, eliminations</li>
<li><strong>chart.js</strong> — dashboard visualisations, loaded from CDN</li>
<li><strong>k3s + Argo CD</strong> — deployed as a Kubernetes pod, Dockerised, managed GitOps alongside everything else on the homelab</li>
</ul>
<p>Source at <a href="https://github.com/janos-gyorgy/dice-n-shrines-stats">dice-n-shrines-stats</a>.</p>
]]></content:encoded></item></channel></rss>