crumble-logbook
A local research tool for Cookie Run: Crumble: what top Korean and global players run in Guild Conquest (길드 토벌전) against the Piñata raid boss, in PvP (Arena and Rumble Arena, 와글와글 아레나), to push main stages and the Dimensional Rift under-powered, and to score in Crumble Dungeon (크럼블 던전), the Golden Drop score attack; and what raises team power (팀투), free and paid, at what cost; and the damage formula the game client computes every hit with. Every claim is traced to the forum post, video or ranking page it came from.
What's here
| Path | What it holds |
|---|
research/ | Research records. Each has a README.md (question, verdict, sources) and evidence/ with verbatim captures (DCInside and Naver cafe posts, comments, images, crumb.gg rankings, YouTube frames) and evidence/captures.jsonl, the capture ledger (see Captures). A record may also keep a research-trail.md (its web search rounds) and a STATE.md (working state between sessions); record 001 has both, its STATE.md now superseded by its README. A record the app can load also has import.json (what to import and from where), curated/ (the curated dataset) and searches.json, its saved searches (schema packages/capture/src/searches.ts), each dated by rounds the record ran: the day import.json says it started, or a ## Refresh <date> section of its README. |
packages/schema | Drizzle tables, migrations and every Zod schema, shared by the server and the web app. |
packages/capture | The capture ledger and the scrapers (DCInside, Naver cafe, crumb.gg, YouTube, and yt-dlp/ffmpeg wrappers), run as pnpm capture. Pure I/O: nothing from apps/. |
apps/server | The data API: Hono over SQLite, layered routes → services → repos, with one content-type registry, plus the record importer and the snapshot CLIs. |
apps/web | The web app: React + TanStack Router/Query, typed against the API through hc<AppType>. One section per game mode (driven by src/app/modes/, a file per mode, and served by the shared routes under src/routes/$mode/), plus the Damage formula (record 007's pipeline of multipliers, crit calculator and community claims against the client's code), Account (the reader's roadmap and lineups), Research, Sources and Glossary. |
data/ | crumble.db (local, gitignored) and snapshot.json, the committed, diffable dump of the database. |
tools/conquest-macro/ | An AutoHotkey v2 loop that retries the Guild Conquest fight; its README covers tuning. The runs and score curves it records locally are charted on Guild Conquest's Attempts tab. |
tools/exp-dungeon-macro/ | An AutoHotkey v2 loop that retries an EXP Dungeon stage until it falls. |
tools/stage-macro/ | An AutoHotkey v2 loop that fights each stage's boss with that stage's movement script (stages/), walking the team onto the boss as the encounter opens. |
tools/rift-macro/ | The stage loop pointed at the dimensional rift: its own button, stage reader and scripts (stages/). |
tools/macro-lib/ | What the macros share: adb screen capture, taps and key holds, the loop's frame (INI, log, status, start/stop, probe, hotkeys), and the boss loop the stage and rift macros run. |
tools/hud/ | Reads numbers off a capture for the macros: the Guild Conquest score, countdown and final score, and a stage's or rift stage's number. |
tools/eslint-config/ | The ESLint flat config (a workspace package). It carries its own TypeScript 6.0, because typescript-eslint needs the JS compiler API, which the native TypeScript 7 behind tsc doesn't ship yet (expected in 7.1). |
docs/superpowers/ | The design specs (specs/) and the implementation plans (plans/). |
OPEN-QUESTIONS.md | Decisions waiting on the user. |
.claude/ | Claude Code skills, hooks and settings used to run the research. |
Development
Requires Node 24.18+ and pnpm 12.6.
pnpm install
pnpm dev # the API and the web app together: http://localhost:5173
pnpm verify # typecheck → eslint → prettier check → vitest
pnpm lint # eslint alone (type-aware, about 45 s); pnpm lint:fix applies the safe fixes
pnpm vitest run packages/schema # one package's tests
pnpm dev:server # API on http://localhost:8787/api
pnpm dev:web # web app on http://localhost:5173, proxying /api (CRUMBLE_API overrides the target)
pnpm icons:fetch # add missing cookie and pet icons to apps/web/public/icons/, then commit them
Icons. Cookie and pet portraits are committed app assets in apps/web/public/icons/, which Vite serves at /icons/ and copies into every build. pnpm icons:fetch (apps/web/scripts/fetch-icons.ts) adds the ones a new patch brings: crumb.gg serves each at /cookies/<key>.webp or /pets/<key>.webp and lists the keys in /data/meta.json. It keeps files already there unless --force; commit what it writes. The app maps a name to its icon through the glossary's extra.resource_key (the game's resource id). For a cookie the glossary has no key for, or an icon not in the folder, a name shows an initials badge tinted by its element.
The server creates and seeds its database from data/snapshot.json when the file doesn't exist yet, so a fresh clone runs with pnpm install && pnpm dev. Delete data/crumble.db to start again from the snapshot. When the file exists, the server compares it with the snapshot on startup and logs a warning naming the records and tables that differ: after a pull that brought newer records, delete data/crumble.db to reseed; after changing a record's data, rebuild the snapshot from a fresh database (see The database) rather than exporting this one. It only warns: it never reseeds or deletes the database itself.
Deployment
The app is published read-only at https://crumble-logbook.vercel.app (Vercel project crumble-logbook, Hobby plan). pnpm build:vercel builds the web app, committed icons included, and writes .vercel/output in Vercel's Build Output API layout: the web app as static files, and apps/server/src/vercel.ts bundled as the function behind /api. That function seeds an in-memory database from data/snapshot.json on every cold start and answers anything but GET and HEAD with 405, so the site shows the committed snapshot and nothing else. The project is connected to the GitHub repo: a push to main deploys to production, and a push to any other branch makes a preview. vercel deploy --prod deploys a local checkout instead, uploading the source without what .vercelignore leaves out. Either way Vercel runs the build, with pnpm from packageManager through corepack (the project's ENABLE_EXPERIMENTAL_COREPACK variable).
ESLint runs typescript-eslint's strict type-checked rules, React's hook rules on the web app, and JSDoc on every function, method, class and interface method (see AGENTS.md). The config and the reason for each switched-off rule are in tools/eslint-config/index.ts.
The database
The server reads data/crumble.db, or the file CRUMBLE_DB names (an absolute path; the scripts run from apps/server). PORT overrides the default port, 8787.
pnpm import:record 001-guild-conquest-meta # load a research record next to any others
pnpm import:record 002-pvp-meta # records load side by side
pnpm import:record 003-stage-pushing-meta # then the stage-pushing record
pnpm import:record 004-golden-drop-meta # then the Crumble Dungeon record
pnpm import:record 005-team-power-growth # then the team power record
pnpm import:record 006-daily-dungeons # then the daily dungeon record
pnpm import:record 007-damage-formula # then the damage formula record (its claims name 001's and 003's mechanics)
pnpm import:record 001-guild-conquest-meta --replace # clear that record's rows and load it again
pnpm import:account # the latest account snapshot and roadmap from account/
pnpm import:account --all --replace # every snapshot and roadmap, replacing loaded ones
pnpm dev:reseed # delete data/crumble.db, then pnpm dev reseeds it from the snapshot
pnpm dev:server # serve the API on http://localhost:8787/api (watch mode)
pnpm db:export # write data/snapshot.json from the database
pnpm db:restore [file] # load a snapshot (default data/snapshot.json) into an empty database
pnpm db:scope HEAD 001-guild-conquest-meta # the records whose rows data/snapshot.json changed, against HEAD; fails on any other
import:record reads research/<slug>/import.json, validates every curated file and every reference (and, when the manifest has a ledger block, the capture ledger against the evidence on disk) before writing, and loads everything in one transaction. An error names the file and the row, and leaves the database untouched. Every row it writes belongs to the record (recordSlug). It refuses a record that is already loaded unless you pass --replace, which clears only that record's rows. Sources, glossary entries and buff values can be shared between records: the first record to load one keeps it, and a later record's differing version is reported as a warning (a differing buff value fails the import instead). It also warns about glossary names that more than one entry claims, and about a curated manifest that lists no decks for a record of a mode with lineups (every mode but team power). After writing, every slug a stored row names must still be a stored row's, so a --replace that drops a row another record's row names fails, naming both.
A recommendation (a row of a table whose entity OBSOLETE_ENTITIES in packages/schema/src/obsolete.ts names) is marked obsolete in its curated file, never deleted: "obsolete": { "since": "YYYY-MM-DD", "reason": "…", "sources": ["dc:…"] }, and on a deck also "superseded_by": "<deck id>". since is the date of the patch that displaced it, or the round's, and never after the record's updated date in meta.json. The import checks the date, that the reason's sources are curated sources, and that the successor is a curated deck of the same mode other than the deck itself; a counter edge that names an obsolete deck must be obsolete too, and a stage zone slot or a dungeon lineup that names one fails the import, naming the row and the deck, until the round points it at a current deck. It writes obsoleteSince, obsoleteReason and supersededBy, keeps the row's status, and cites the reason under the cited entity obsolescence, keyed <entity>:<id>. Removing the block and re-importing makes the row current again.
Game facts (the tables packages/schema/src/tables/stage.ts marks as such) are owned by no record: they have no recordSlug. Instead, each record that lists a fact claims it (fact_claims, one row per source the record cites for it), and the fact is cited to every source its claims cite. A record that loads a fact already stored must agree with it: an identical fact isn't written twice, only claimed; a differing one fails the import. --replace drops the record's claims before loading it again, so it refreshes the sources the record gives a fact, and a fact the record no longer lists is deleted once no other record claims it. A fact written through the API is claimed by no record, and an import leaves it alone unless a record claims it, which then sets its citations.
Multi-record gotchas:
- Import the records in number order, 001 first, one command each. Shared sources and glossary entries keep the first record's row, so the order decides which version the database holds.
data/snapshot.json is always built from a fresh import of every record, in that order, into an empty database (point CRUMBLE_DB at a new file), then pnpm import:account --all when the account belongs in it, followed by pnpm db:export.
- A record may name cookies only another record's glossary knows: the Crumble Dungeon record's lineups and exclusions must each name a cookie by a glossary entry's Korean name, loaded already or by the record itself, or its import fails. Record 004 relies on record 001's glossary.
- After a scoped
--replace, id counters restart past the highest id left, so ids no longer match a fresh import.
- Shared buff values belong to the record that loaded them first. Replacing that record clears them before it writes its own.
The reader's account. import:account reads account/ (committed, but for the screenshots under snapshots/screens/; CRUMBLE_ACCOUNT overrides the folder): snapshots at snapshots/<date>.json and roadmaps at roadmap-<date>.json, each keyed by its file name, which must lead with a YYYY-MM-DD date. With no argument it loads the latest of each; given ids it loads those files; --all loads every one, so older snapshots stay loaded for an audit to compare against. A file already loaded is skipped and reported, so a later audit that writes only a new snapshot loads it under the same roadmap; one named by id is refused instead, and --replace reloads either. An unknown flag is refused. The shape is the audit's (account/README.md); the file schemas (packages/schema/src/account.ts) read it loosely. A snapshot's lineups are keyed by lineup, each with its mode, power, captain, pets, gearPreset, matchesDeck and its cookies under rows (rows of cookies) or cookies, or a note in their place ("same as arena_def" copies that lineup's cookies, through a chain of such notes; one that names no lineup's cookies stays as the note and is warned about). A cookie names itself by kr, en and resourceKey; its level, stars, skillTier, power, promotion and runes come from cookies.list, matched by resource key or name, and its runes become a chip per stat under a short label (Skill AMP 21 ×5), summing only the lines read, with unread lines as +1? (or ? when none was read). account gives the profile figures (a later reading under account.later wins, marked with its time), resources the balances (a null value is unread and skipped), pets.list the pets, and unread what couldn't be read. A roadmap has items (priority of now, next or later, action, and optional area, why, payoff, cost, size (the payoff's size, big, medium or small, which the payoff badge shows in place of the payoff's lead clause; kept as ) and : , , or ), , and . A ref is found only when its row exists and belongs to the record it names; a ref needs only the record. Fields they don't map are kept under , and screenshots are dropped (, and fields, and any image path): they stay local and never reach the database or the snapshot export. It warns about cookie names the glossary doesn't know, actions longer than twelve words, refs that name nothing loaded and notes that name no lineup. No record owns the account rows; they are in the snapshot export like any table, so publishes them: a snapshot built for the deployment includes the account only if ran before the export.
A row created through the API (POST) gets recordSlug null: no record owns it, so a ?record= filter doesn't list it on its own account, its names are glossed without a record's preference, and --replace never clears it. To keep such a row with a record, add it to the record's curated/ files and re-import.
Commit data/snapshot.json after changing a record's data, so the history stays diffable, and build it as above, from a fresh import into an empty database: a database that --replace or the API changed holds ids and rows a fresh import wouldn't make, so exporting it would commit them. To rebuild a database from the snapshot, point CRUMBLE_DB at a new file and run pnpm db:restore.
pnpm db:scope <rev> [<record slug> …] checks which records a regenerated snapshot touches, against data/snapshot.json at a git revision. It compares each record's rows by content, integer ids aside, since import order assigns them; a row's child rows (a deck's cookies, pets and notes, a rune build's decks) and its citations, an obsolete reason's among them, count as part of the row. It prints each changed record with its tables and fails when a record it wasn't given changed. Game facts that changed get a line of their own and don't fail it; a record that claims a changed fact is named too.
API
Every resource is under /api and speaks JSON. Validation failures are 400 with every Zod issue and its path, unknown ids are 404, an unknown cited source or deck is 422 naming the ids, and conflicts are 409: deleting a source that rows still cite or a deck that a counter edge names, or a row whose mode isn't the mode of a deck it names. Content rows carry sources, the ids of the sources they cite; creating one needs at least one. Every list whose rows have a game mode takes ?mode=guild_conquest|arena|rumble_arena|stage|crumble_dungeon|team_power|daily_dungeon|damage_formula (damage_formula is no game mode: it files the damage formula record's rows, which the Damage formula section shows). Every list whose rows carry the obsolete lifecycle takes ?current=true|false (true keeps the current rows, false the obsolete ones), and its rows carry obsoleteSince, obsoleteReason and obsoleteSources (the sources of the reason, empty while current); a deck also carries supersededBy. Only a record's import marks a row obsolete: POST and PATCH ignore those fields. The stage tables, the Rift's included, belong to the stage mode alone, the dungeon tables to Crumble Dungeon alone, the daily dungeon tables to the daily dungeons alone and the team-power tables to team power alone, so they have no mode column, and the decks their rows name must be of that mode (409 otherwise). The team-power rows name each other by slug (a power source, a data point, a package, a spending order): a slug that isn't stored is 422 naming it, and deleting a row, or changing its slug, while another row names it is 409, as is a change to a row that leaves a row naming it breaking its own rule. Sources a row names inside it (a posted gain's, a curve row's) must exist and are cited like its own, so the row's sources lists them too. Names are glossed with the glossary entries of the row's own research record first.
| Resource | Verbs | Notes |
|---|
/api/decks | GET, GET /:id, POST, PATCH /:id, DELETE /:id | :id is a slug. Cookie and pet names come back with their glossary English (en, null if unresolved); cookies carry their formation slot when known. A PATCH that changes a deck's mode while a counter edge, a stage row or a dungeon row names it under its old mode is 409, and so is one that would leave an obsolete deck and the deck that superseded it on different modes, from either side. A deck another deck names as supersededBy can't be deleted (409). A daily_dungeon deck carries dailyDungeon, its run facts: the dungeon it runs (a stored daily dungeon, else 422), auto (full, semi, manual), stage, power and recommendedPower as posted with powerG and recommendedPowerG read from them, gearPreset, and captain (one of its cookies, glossed); every other deck has null. A POST that breaks this is 400, a PATCH 409. |
/api/counters | GET, GET /:id, POST, PATCH /:id, DELETE /:id | Directed edges: teamDeckId is beaten by beatenByDeckId, under conditions, because of why. Both decks must be of the edge's mode (the import enforces the same rule). ?deck= lists the edges on either side of a deck. |
/api/usage | GET, GET /:id, POST, PATCH /:id, DELETE /:id | Usage figures, highest usagePct first, each with its sample and capture date. ?kind=cookie|core|pet|team filters. |
/api/rune-builds | GET, GET /:id, POST, PATCH /:id, DELETE /:id |
SQLite driver: Node's built-in node:sqlite, through the drizzle-orm/node-sqlite driver in drizzle-orm 1.0 beta. It needs no native build, which matters on ARM64 Windows. Zod schemas come from drizzle-orm/zod, the 1.0 home of drizzle-zod. The fallbacks (better-sqlite3, @libsql/client) weren't needed. The spike, run 2026-09-27 on Node 24.18.0 ARM64, is kept as packages/schema/test/driver.test.ts.
Working notes
- Commits: the
require-commit-format hook blocks git commit -m. Write the message (subject, then a body with the problem, the justification and what was discarded) to a file and run git commit -F <file>.
- Gates: run
pnpm verify. The prefer-verify-script hook blocks chained gates and unscoped test runs. The rtk hook masks prettier's output, so an agent runs rtk proxy pnpm verify.
- Evidence scripts (
research/*/evidence/*.py): set PYTHONIOENCODING=utf-8. The Python scrapers are retired (see Captures); they stay, unedited, as the record of how records 001 and 002 were captured.
- Names: the KR↔EN glossary is
research/001-guild-conquest-meta/evidence/12-glossary.json and each record's curated/glossary.json. Resolution is per record: 바궁 is Princess Bari in 001 and Wind Archer in 002.
- Refreshing the meta: after a patch, or when you say "refresh", the
refresh-meta skill (.claude/skills/refresh-meta/) adds a dated round to each research record: new captures in evidence/r<date>/, displaced recommendations marked obsolete, a ## Refresh <date> section in the README, and the snapshot rebuilt.
- Running the stage loop: the
stage-macro skill (.claude/skills/stage-macro/) is the operating procedure for tools/stage-macro: starting and resuming it, watching its log, writing each stage's script from its recording, and the restart gotchas. Update it whenever the loop or the way it's run changes.
- Roadmap (the user's, 2026-09-28): simulators for every game mode, starting with the Guild Conquest one specced in
docs/superpowers/specs/2026-09-27-conquest-simulator-design.md; scrapers and easy ways to plug in more decks and research records; and more game modes, such as stage-pushing teams and the Golden Drop encounter, each with its own views, backend logic and simulator. The architecture audit against this roadmap is docs/architecture/2026-09-28-audit.md.
- Next research steps:
- Re-pull crumb.gg's final Season 5 boards after 2026-09-28 12:00 KST, as new evidence files.
- Open research questions: the exact survival build for the 17 s wipe (about 9M HP and 45% DR per survivor); whether top players run Herb or other survival fillers.
Captures
Captures are taken by scripts and agent-browser sessions, never from the app. Every file under a record's evidence/ has a line in that record's evidence/captures.jsonl: its path, URL (null for a derived file), capture time, tool and sha256. The design is docs/superpowers/specs/2026-09-28-capture-ledger-design.md.
pnpm capture dc list <record> <out.tsv> <pages> <query>... # DCInside search listing (name:, subject:, memo:, comment:, @recommend)
pnpm capture dc fetch <record> <outdir> <no>... # posts, their images and comments
pnpm capture naver list <record> <out.tsv> <menuId> <pages> # a Naver cafe board listing
pnpm capture naver fetch <record> <outdir> <articleId>... # articles, their images and comments
pnpm capture crumbgg <endpoint> <record> <outdir> [args] # crumb.gg's public JSON (live, rankings, data, api-* ...)
pnpm capture youtube watch|search|download <record> <outdir> ... # watch pages and digests, searches, yt-dlp downloads
pnpm capture youtube frames|sheet|subs <record> ... # ffmpeg frames, contact sheets, subtitle-band sheets
pnpm capture log <record> <path> --url <url|-> --tool <tool> # a file captured any other way (agent-browser, curl, manual)
pnpm capture verify [record] # check every ledger against the files on disk
pnpm capture backfill <record> [--from <dir>] # write the ledger of a record that predates it, once
<record> is the folder name under research/, and every path is record-relative under evidence/. A scraper appends each file's ledger line as it writes it and never overwrites a capture; pnpm capture with no arguments prints the full usage. dc fetch and naver fetch write a post only once the whole post (page, images, comments) is fetched, so a rerun after an interrupted one is safe: a file already captured is kept, and a file that differs from its line, or sits on disk without one, stops the run with its name and is left untouched. Tools are named capture:<scraper>, agent-browser, curl, yt-dlp, manual, python:<script> (the retired scrapers) or unknown (backfill only).
- The Python scrapers are retired.
dc_scrape.py became pnpm capture dc, nv_scrape.py pnpm capture naver, ytv.py and ytall.py pnpm capture youtube watch and search, and frames.py, sheet.py and subs.py the youtube frames, sheet and subs wrappers over ffmpeg (which needs ffmpeg and yt-dlp on PATH). frames and subs pass -fps_mode, so they need ffmpeg 5.1 or later. The ports write the same TSV, Markdown and digests, byte for byte; the tests hold them to Python-made captures.
- Media stays local. Images, frames and video are gitignored under
research/ (the extensions are MEDIA_EXTENSIONS in packages/capture/src/ledger.ts, kept in step with .gitignore). Their ledger lines are committed; a clone without the files still verifies, and a present file's hash is checked.
- Backfilled lines say how their time was found in
approx: header (the capture's - captured: line), post (an image takes its post's time) or git (the file's first commit). Records 001 and 002 were backfilled from the main checkout, media included.
- Hash cache. Verification hashes every present evidence file, media included, and caches each hash in
node_modules/.cache/crumble-capture/hashes.json by absolute path, size and mtime, so later runs (every test worker, import and pnpm capture verify) rehash only files that changed. Delete the file to force a full rehash.
- Line endings. Text under
research/ is stored with LF. A ledger line hashes the bytes git stores, so pnpm capture log on a file written with CRLF records its LF hash, unless a .gitattributes marks the path -text and git keeps its bytes. A file written with CRLF before normalisation still matches its LF hash; anything else that changes a capture's bytes fails pnpm verify.
The evidence/ folders hold publicly posted community content, captured for research and citation. Game content and names belong to Devsisters.