A team-comedic, engineering-themed dungeon-crawl that runs in Slack and in a companion web dashboard, sharing a single state. Cloudflare Workers + D1 + Workers AI + Durable Objects. No real coworkers as NPCs β names are generated.
The repo is named
slack-queston GitHub for historical reasons; the bot ships as Gantt Quest via theBOT_NAMEenv var. Rename it for your team β the Cloudflare Worker name, D1 database name, and Slack app display name can all be changed without touching engine code.
Two Cloudflare Workers and a small set of Durable Objects share one D1 database:
βββββββββββββββββββββββ ββββββββββββββββββββββββ
β apps/slack β β apps/web β
β (Slack worker) β β (Web worker + SPA) β
β β β β
β β’ slash commands β β β’ dashboard + town β
β β’ interactive btns β β β’ web combat (WS) β
β β’ LobbyManager DO β β β’ QuestRoom DO β
β (alarm timing) β β β’ LobbyRoom DO β
ββββββββββββ¬βββββββββββ ββββββββββββ¬ββββββββββββ
β β
ββββββββββββ¬ββββββββββββββββββ
β
ββββββββββββΌβββββββββββ
β D1 (one database) β
β + R2 (art cache) β
β + Workers AI β
βββββββββββββββββββββββ
- Slack worker handles
/sq β¦slash commands, interactive button payloads, and the singletonLobbyManagerDO that schedules lobby auto-start alarms and delayed "your turn!" notifications. - Web worker serves the React dashboard, exposes the JSON + WebSocket
combat API, and hosts the
QuestRoom(per-quest live combat) andLobbyRoom(per-quest live lobby + chat) Durable Objects. Both DOs use the Hibernation API so idle connections cost nothing. - Cross-binding: the Slack worker holds DO bindings into the web worker
(
QUEST_ROOM,LOBBY_ROOM) so Slack actions instantly push updates to web clients without polling. - D1 is the system of record. DO in-memory caches always rehydrate from D1 after hibernation; nothing critical lives only in DO storage.
- Soft death by default. At 0 HP on a standard quest you're "downed,"
lose 25% gold + a random unequipped inventory item, and can't quest for 12h.
Elite quests (
/sq quest elite) opt into perma-death β the character row is deleted and survivors fight on. - Mechanics deterministic, flavor AI-written. Damage rolls, status procs,
drop rates, and XP math run on a pure engine (
packages/core). Workers AI only writes the prose that wraps the result (opening scenes, hit flavor, item names, dungeon room descriptions). If an AI call fails, the deterministic line is shown. - Single source of truth across surfaces. Slack
/sq attackand the web Attack button hit the same engine viaQuestRoom. Lobby accept/decline from Slack pushes to the web LobbyView in real time. - Two-surface visibility. Most narration stays in the quest thread.
Big beats (joins, gauntlet wave transitions, boss phase 2, perma-death,
victories, expedition treasure reveals, lobby start) use Slack's
reply_broadcastto surface in the channel and toast the web dashboard. - No GitHub integration. Quests are AI-flavored from generic prompts.
/sq roll creates a character with a random class + AI-generated name. One
character per Slack user. Classes share five primary stats (STR / INT / VIT
/ AGI / DEX) β you spend points at level-up via /sq spend <stat> (and from
the web stat-allocator). Attack mod = floor((STRβ5)/2); magic mod =
floor((INTβ5)/2).
DevOps Mage β 22 HP | STR 4, INT 9, VIT 5, AGI 6, DEX 6
- Passive - Mana Font - +1 mana every 3 turns
- Active - Fireball - 2 mana, 1 turn cooldown.
magicΓd6fire AoE to all enemies - Active - Lightning Bolt - 1 mana. d20+mag to hit,
magicΓd8lightning damage - Active - Mage Armor - 1 mana.
3d6+magshield to an ally - Active - Containerize - 2 mana, 1 turn cooldown. Stun the monster; each stunned turn it has a 30% cumulative chance to break free (guaranteed on the fourth turn)
QA Paladin β 28 HP | STR 9, INT 4, VIT 9, AGI 4, DEX 4
- Passive - Holy Rage - Whenever you or an ally takes damage, your next attack deals +10% of that damage (stacks)
- Active - Smite - 1 mana, 1 turn cooldown.
1d6+atk+wpn+2d8damage; enemy deals β50% on their next swing - Active - Shield of Faith - 2 mana. +5 AC party-wide for 3 rounds
- Active - Lay on Hands - 1 mana.
1d6+mag/2+vit/2heal to an ally; if the target is your protected ally, also heal yourself for the same amount - Active - Protect - 0 mana, 2 turn cooldown. Target an ally: they take half damage, you absorb the other half. Target yourself instead to gain
2d6+mag/2+vit/2shield
Backend Druid β 24 HP | STR 6, INT 7, VIT 6, AGI 5, DEX 6
- Passive - Primal Strikes - Magic mod boosts attack to-hit and damage; dealing attack damage heals you for
2Γmag+attack - Active - Wildgrowth - 2 mana, 2 turn cooldown.
3d6+mag+atkAoE + entangle (β4 to-hit) for 2 rounds - Active - Regeneration - 1 mana.
magHP/turn heal-over-time on an ally for 4 rounds - Active - Barkskin - 1 mana. +5 AC on an ally for 2 rounds
- Active - Animal Form - 2 mana. Transform for 4 rounds β STR, VIT, AGI, and DEX each increase by
mag + 25%of their current value
Frontend Bard β 20 HP | STR 4, INT 9, VIT 5, AGI 6, DEX 6
- Passive - Bardic Aura - Party deals
+(1+βlv/5β)bonus damage while you're alive - Active - Crescendo - 1 mana. d20+mag to hit;
1d6+mag+partyΓ2+wpndamage on hit - Active - Verse - 0 mana, 2 turn cooldown. Mock an enemy (disadvantage) or encourage an ally (advantage) on their next 2 rolls
- Active - Battle Hymn - 2 mana. Bardic aura surges for 3 rounds (+2+magic bonus damage on top of the base aura) and restores mana to all allies
- Active - Serenade - 2 mana.
2d6+magheal + small shield on a chosen ally
Staff Sage β 26 HP | STR 4, INT 10, VIT 6, AGI 5, DEX 5
- Passive - Foretell - Always see monster damage rolls and next intended targets
- Active - Ray of Frost - 1 mana. Spell attack (+mag to hit),
magd4 frost damage with a 25% chance to freeze - Active - Blizzard - 2 mana, 3 turn cooldown. AoE frost dealing
1d6+magto all enemies at the end of each of your turns for 3 turns (10% freeze chance per hit) - Active - Good Fortune - 1 mana, 1 turn cooldown. Heal an ally for
1d4+mag; a delayed heal for double that amount triggers on your next turn - Active - Ill Omen - 1 mana, 1 turn cooldown. Curse an enemy; tracks all damage it takes and deals 50% of the total back to it after 3 of its turns
Refactor Rogue β 18 HP | STR 7, INT 4, VIT 4, AGI 7, DEX 8
- Passive - Lethal Strikes - Crits apply
2+βlv/2βbleed stacks - Active - Backstab - 0 mana, 2 turn cooldown. Attack with advantage; roll damage twice and take the higher roll
- Active - Vanish - 2 mana. Untargetable for 2 rounds; attacks from Vanish auto-crit on hit
- Active - Envenom Weapon - 1 mana. Next 2 hits each apply
2+lvpoison stacks - Active - Debilitate - 1 mana, 3 turn cooldown. Stun for 1 round + 20% vulnerability for 2 rounds
SRE Warden β 30 HP | STR 9, INT 4, VIT 10, AGI 4, DEX 3
- Passive - Thorns - Reflect 25% of your armor value back to any attacker that hits you
- Passive - Armor Up - Regenerate
2+βlv/4βshield at the start of each of your turns - Passive - Resilient - Each successful attack hit grants a Resilient stack (lasts 4 rounds). Each active stack raises your effective armor and shield cap by
2+βvit/4β, boosting Thorns, Brace, and the maximum shield you can hold - Active - Bulwark Strike - 0 mana, 2 turn cooldown. d20+atk to hit;
1d10+atk+50% current shielddamage - Active - Taunt - 2 mana. Force all monsters to target you for 2 swings; all incoming damage routes through your armor for 2 turns
- Active - Brace - 0 mana, 4 turn cooldown. Restore 50% of your effective armor as shield + β20% incoming damage for 2 turns
Data Warlock β 22 HP | STR 4, INT 10, VIT 5, AGI 5, DEX 6
- Passive - Sinister Queries - Dealing any damage applies
1+βlv/5βbleed stacks to the target - Active - Hex - 1 mana. β25% monster damage; hexed target gains 3 bleed stacks on each damage received
- Active - Leech Life - 1 mana.
2d6+magdamage + heal 50% of damage dealt - Active - Forbidden SQL - 2 mana. Consume all bleeds for
(2+βmag/4β)Γstacksdamage - Active - Summon Imp - 2 mana, 2 turn cooldown. Summon an imp whose attacks deal damage equal to your magic modifier
(Xm) = mana cost; (Xcd) = cooldown in turns after use. Active abilities cost mana.
Mana refills between quests and at level-up. Magic-type drop items grant
+1/+2/+3 max mana when consumed (capped at 5).
Started via /sq quest <variant> [elite] (or the Job Board for posted contracts):
standardβ single (or multi-monster pack) fight.boss(L3+) β single tougher monster with 2 phases at 50% HP. 2Γ rewards.gauntlet(L5+) β 3 monsters back-to-back, no flee, party locked at start. 3Γ rewards, guaranteed drop on the final kill.dungeon(L4+) β grid-shaped expedition. AI generates the room theme + per-room scene art, and a graph of encounters / loot / lockboxes / traps / NPCs / merchants / boss room. Navigate via/sq move <dir>(or the web compass). 2.5Γ rewards.bounty_packβ multi-monster brawl. Each monster carries its own weakness/resistance profile.
elite is a modifier that composes with any variant β turns on perma-death.
The Job Board (/sq board or web town β Job Board) lists 3 posted-contract
quests at any time. Accepting one starts that variant with a small reward bonus
(from_job_board flag at victory).
Every quest opens in a lobby before combat starts. Slack and web stay in sync
via the LobbyRoom DO (WebSocket push from the web side, RPC from Slack).
- Invites & ready-up β the creator invites teammates by
@user. Each invitee Accepts or Declines; accepted players Ready Up. When all accepted are ready, the quest auto-starts. - Force Start β creator skips remaining invites. Pending invitees are dropped (no party HP scaling).
- Auto-start alarm β 5 minutes after creation via the
LobbyManagerDO. Same flow as Force Start. - π Lock β creator toggles the chest icon to reject new invites + new
/sq joincalls. Already-pending invitees can still accept. - π Cancel β creator can abort.
- Pre-combat: quest row +
quest_partycascade deleted. - Mid-combat reinforcement lobby: only the pending invitees are dropped; the active fight continues untouched.
- Pre-combat: quest row +
- π Reinforcement lobbies β a creator on an active quest can click
"Call Reinforcements" on the active-quest card. New invitees see a single
"Join the Fight!" button that atomically accepts + refills mana + scales
monster HP + pulls them into the live combat state via
QuestRoom.notifyFighterJoined. No "wait for the rest to ready up" gate. - Real-time chat β ephemeral per-quest chat lives in
LobbyRoom's in-memory ring buffer (50-message cap, dropped on hibernate, never persisted to D1). Disappears when combat starts.
Turn-based, dice-driven, engine-pure. The same engine (packages/core) drives
both Slack and web combat β actions are deserialized into a TurnAction,
applied with step(), and the resulting state + events are broadcast.
/sq attackβ 1d6 + weapon power (halved) + STR-derived bonus; crit Γ2 on a nat 6./sq signatureβ class damage signature; costs 1 mana (see class table)./sq ability <id>β class utility/support ability (Taunt, Vanish, Lay on Hands, etc.). Healing and support are entirely ability-driven β there is no universal/sq healor/sq cast. Each class's kit covers its role./sq revive <id> @userβ consume a revive item to bring a downed partymate back at 50/75/100% HP (rarity-tiered). Can't self-revive./sq mark <target>β focus-fire tag: party attacks get a bonus until expiry. Self-attacks by the marker DON'T get the bonus./sq position <front|back>β swap rows. Front row eats hits first; back row gets reduced damage from melee but can be targeted by ranged./sq fleeβ 1d2 self-escape. Quest fails only if you were the last./sq use <item>β combat-usable items (consumable / tool / scroll). Damage tools cap atmonster_hp - 1so they never deliver the killing blow.
Every attack has a damage_type: physical | magic | fire | ice | lightning.
- Physical routes through your armor pool first (depletes shield,
then HP). The pool's max is
floor(armor_power / 2)wherearmor_powercomes from equipped gear (body + helmet/2 + pants/4 + shield). - Magic / fire / ice / lightning bypass the armor pool entirely and
hit HP directly. Your
resist_<type>stat (from gear stat_bonus) reduces the final damage by that percent.
Monster attack types are visible on the in-combat MonsterCard and the Active Quest preview ("π₯ FIRE ATTACKS" pill) so you can plan loadouts before clicking Open Combat.
- Burning β DoT, ticks at turn start.
- Frozen β your next turn is skipped (turn_skip event + particle burst).
- Shocked β incoming damage is amplified (Γ1.30 mag 1, Γ1.45 mag 2).
- Poisoned / Bleeding β DoT variants from tools or weapon procs.
- Regen β HoT from heal items or Druid passive.
Sources:
- Player weapons with an element proc statuses on monsters (rate by rarity).
- Monsters with fire/ice/lightning attack_damage_type roll a 25%
base chance Γ
(1 β resist/100)on a successful hit to inflict the matching status on the player. Lightning hits also arc to same-row allies β each ally rolls independently with their own resist.
Status pills render on every fighter and monster card with matching color
- icon + turn-remaining suffix.
- Dust puffs burst from a fighter card on every landed hit (Wile-E- Coyote tan/brown clouds, drift up + out).
- Particle bursts (Web Animations API) fire on element procs, hits, frozen / victory.
- Hit flash + slash streak on the target card.
- Toast when a status proc lands on the local player ("π₯ You're now burning! (3t)").
Fighters occupy a front or back row. Multi-monster fights (bounty_pack,
gauntlet waves) render a horizontal strip of MonsterCards β each one is
individually targetable (click to mark, then /sq attack).
The web dashboard renders Town as a hub of locations; Slack mirrors most of these as slash subcommands.
- πͺ Shop β 5 AI-generated rotating items + always-in-stock staples (potions). Stock is channel-scoped and restocks every 6 hours. Haggle is a free action (per item, per cycle). Bards / Sages / Rogues get a bonus on the haggle d6.
- π¨ Smithy β sharpens weapons (caps at SHARPEN_CAP per item) and repairs the armor pool to max for gold. Also sells rotating armor-only stock (channel-scoped, mirrors shop semantics). Sage's Foresee doesn't apply here β smithy items are open inventory.
- ποΈ Inn β paid alternative to long rest. Skips the 24h cooldown by spending gold. Restores HP / mana / armor pool.
- πΊ Pub β buy drinks for between-quest combat buffs (Tavern Ale, Espresso, Whiskey, etc.). Capped at 2 drinks between quests; resets on joining/starting a quest. Pub also hosts a 1v1 Stone-Parchment-Dagger betting game.
- π Job Board β 3 posted-contract quests at any time. Accept one for a reward bonus at victory.
- π§ͺ Apothecary β buy consumables / scrolls / revive items at fixed prices. Inventory rotates daily.
- π² Outskirts (Hunt) β start a custom-tier hunt quest. Pick a tier and monster count.
- π€ Rest β short rest (10-min cooldown, +50% missing HP) or long rest (24h cooldown, full HP + mana + armor pool). Mid-quest rest is blocked.
Dungeon quests generate a small grid of rooms β entry, encounters, lockboxes,
traps, NPCs, merchants, loot rooms, and a boss chamber. Navigate via the
compass (web) or /sq move <dir> (Slack). All grid loot is AI-flavored at
dungeon-start time (boss treasure, lockbox options, npc offers, merchant
stock all get proper names + flavor blurbs).
- Closed β chest shows lock tier; spend a matching key (bronze/silver/ gold) to open. Higher-tier keys open lower-tier locks. Staff Sages see a "Foresee" preview of contents while the chest is still closed (others see only "N mystery items await").
- Open β every party member can claim any unclaimed item. First click per slot wins; multiple players can claim from the same chest, or one player can grab everything.
- Resolved β auto-resolves when all items are claimed, or anyone can "Close chest" to walk away leaving spoils behind.
Each trap room offers three skill checks (STR / DEX / INT). Pass the d20
to bypass; fail to take 4 + level HP damage. Trap rewards vary by skill
(INT pass restores mana, etc.).
- Slots: main_hand, off_hand, body, helmet, pants, boots, ring, amulet.
- Weapon ranges: melee, ranged, focus. Focus weapons boost heal/shield rolls and grant +1 max mana while equipped (doesn't add to attack/cast damage).
- Element-tagged weapons proc burning/frozen/shocked on monsters at a rarity-scaled rate.
- Stat bonuses β gear can carry
stat_bonuslike{str: 2, resist_fire: 30}. Bonuses sum across equipped slots; resist values cap at 100% (immunity). - Inventory β
/sq inventorylists items./sq equip <id>//sq unequip <id>,/sq give <id> @user(transfers as unequipped β and fires a π toast in the recipient's web dashboard). - Level gates β every drop carries a
level_req(defaults toceil(power / 3)). Surfaced in tile UIs so you don't claim something you can't equip.
Victory rewards split using a two-pool formula:
- 40% of the pool is split equally across alive party members (participation share).
- 60% is proportional to each fighter's contribution score:
damage_dealt + 0.75 Γ healing_done + 0.5 Γ shielding_done.
So a heavy damage-dealer still tops the table, but supports get a meaningful slice. Pool size scales with monster tier Γ multipliers (boss Γ2, elite Γ1.5, party size bonus). Level-up rolls 1d6 to add max HP and refills the bar.
Loot drops roll independently per fighter at 35% + 5% per monster tier (cap 70%). Each drop rolls slot, rarity, and power; the AI generates the name + flavor.
Curated single-shot offensive consumables with fixed names. Each maps
item_name β effect in packages/core/src/flavor.ts. The AI writes
per-drop flavor text. Power rolls at create time and does not scale β
buy a Caffeine Bomb at L1 and it stays L1-tier forever.
| Type | Item | Effect |
|---|---|---|
| 𧨠tool | Caffeine Bomb | 2 + tier dmg, ignores armor |
| π₯ tool | Hotfix Grenade | 6 + tierΓ2 dmg, ignores armor |
| β tool | Espresso Shot | Regen self for 5 turns |
| π§ͺ tool | Poison Vial / Venom Vial | Apply poisoned status, 4 ticks |
| π©Ή tool | Regen Draft | Self-regen, 3 turns |
| β tool | Battle Elixir | +25% damage, 3 turns |
| π scroll | Rebase Scroll | Free action: full party mana refill, no retaliation |
| π₯ scroll | Production Outage | Non-boss instakill / Boss β30% HP |
| π tool | Crowbar of Last Resort | Force-pick a dungeon lockbox |
Web dashboard fetches /api/notifications/pending on initial load and on
every tab focus. Currently surfaces:
- π item received β when another player gives you an item.
Notifications are stored in D1 (notifications table) and delete-on-read β
each toast fires exactly once across tabs/devices.
apps/slack/ β Slack worker
src/index.ts β Hono entrypoint, signature verify, LobbyManager DO
src/commands.ts β slash sub-command dispatch + handlers
src/ai.ts β Workers AI scene generation + parser
src/slack.ts β Slack Web API helpers
apps/web/ β Web worker + SPA
src/worker.ts β Hono server, REST + WS endpoints, QuestRoom DO,
LobbyRoom DO
src/App.tsx β dashboard root, town, party, character, inventory
src/CombatPage.tsx β standalone combat (outskirts/boss/gauntlet/standard)
src/GridDungeonView.tsx β embedded combat + dungeon navigation (graph dungeons)
src/CombatShared.tsx β shared combat UI (HpBar, ItemPicker, HitDust, etc.)
src/CombatParticles.tsx β Web-Animations-API particle bursts
src/LobbyView.tsx β sliding lobby drawer with chat
src/icons.tsx β icon component + class portrait helpers
src/main.tsx β entrypoint + Toaster mount
packages/core/ β pure engine (no DB / no network)
src/combat_machine.ts β TurnAction β CombatState reducer (`step()`)
src/combat.ts β legacy combat helpers (pre-engine paths)
src/flavor.ts β class table, signature/ability registry,
item catalog
src/*.test.ts β Vitest suite
packages/db/ β D1 query helpers + shared types
src/db.ts β characters, quests, lobby, party, inventory,
shop/smithy stock, notifications, β¦
src/dungeon_grid.ts β pure grid generator for dungeon variants
migrations/ β D1 SQL migrations (numbered)
pnpm install
pnpm db:createCopy the database_id Wrangler prints into apps/slack/wrangler.jsonc and
apps/web/wrangler.jsonc (both workers share the same DB).
pnpm db:migrate:local # local development
pnpm db:migrate:remote # against the deployed D1Use this manifest at https://api.slack.com/apps?new_app=1 β "From an app
manifest". /sq is the convention but any unreserved trigger works β Slack
reserves /dnd. The bot reads its own command name from the slash payload,
so help text reflects whatever you choose.
display_information:
name: Gantt Quest
features:
bot_user:
display_name: Gantt Quest
always_online: true
slash_commands:
- command: /sq
url: https://your-slack-worker.workers.dev/slack/commands
description: Roll a character, start a quest, check your sheet
usage_hint: roll | me | quest [elite] | help
should_escape: false
interactivity:
is_enabled: true
request_url: https://your-slack-worker.workers.dev/slack/interactions
oauth_config:
scopes:
bot:
- chat:write
- chat:write.public
- commands
- im:write # required for cross-surface DMs
- users:read # required for invite lookups
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: falseInstall to your workspace, copy the bot token (xoxb-...) and signing secret.
Both workers need their own secret set. For the Slack worker:
cd apps/slack
npx wrangler secret put SLACK_SIGNING_SECRET
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put ALLOWED_CHANNEL_ID # optional channel allowlist
npx wrangler secret put IMAGE_BASE_URL # public URL of the deployed web workerFor the web worker:
cd apps/web
npx wrangler secret put SLACK_CLIENT_ID
npx wrangler secret put SLACK_CLIENT_SECRET
npx wrangler secret put SLACK_BOT_TOKEN # for cross-surface notificationsThe web worker uses Slack OAuth for sign-in; the bot token lets it post "Player joined from the web" messages to the quest thread.
Both wrangler.jsonc files need their DO bindings configured:
apps/web/wrangler.jsoncregistersQUEST_ROOMandLOBBY_ROOMas owned classes (new_sqlite_classesmigrations).apps/slack/wrangler.jsoncregistersLOBBY_MANAGERas owned, plus cross-bound stubs forQUEST_ROOMandLOBBY_ROOM(script_namepointing at the web worker name).
Deploy the web worker first so the DO classes exist before the Slack worker tries to bind them.
Two files control local dev behaviour. Both are gitignored β create them by hand after cloning.
apps/web/.dev.vars β injected into the web worker runtime by Wrangler /
@cloudflare/vite-plugin. Also parsed by vite.config.ts at startup for
build-time flags.
# apps/web/.dev.vars
# Gates all dev-only features in the web worker. When set to "local":
# - POST /api/dev/login is enabled (powers the "Dev login" button)
# - POST /api/dev/* tool endpoints (heal, mana, gold, revive, level,
# cooldowns, combat-heal, combat-mana) are enabled
# - Workers AI art generation is disabled (avoids "must run remotely" errors)
# - hunt/shop/smithy fall back to a synthetic "local-dev" channel so they
# work without a real Slack channel in the DB
# Set to "production" (or omit the file) to match deployed behaviour.
ENVIRONMENT=local
# Controls whether the Cloudflare Vite plugin uses remote CF bindings (AI,
# R2, etc.) or local Miniflare stubs. Read by vite.config.ts at Vite startup;
# has no effect on the worker runtime itself.
# false β use local Miniflare stubs (default for local dev; AI won't work)
# true β call real Cloudflare services (requires auth; lets you test AI art)
# Set to "true" if you want to test AI art generation locally.
REMOTE_BINDINGS=falseapps/slack/.dev.vars β injected into the Slack worker runtime only.
# apps/slack/.dev.vars
# Same ENVIRONMENT flag as the web worker. When "local", no behaviour is
# currently gated in the Slack worker, but the flag is wired up for future
# use and keeps the two workers consistent.
ENVIRONMENT=local
# Public base URL where Slack fetches images (banners, monster art). Overrides
# the production URL hardcoded in wrangler.jsonc. Point at the local Vite dev
# server so image routes resolve during local development.
IMAGE_BASE_URL=http://localhost:5173Production values live in the vars block of each wrangler.jsonc
(ENVIRONMENT=production), so dev endpoints are always unreachable in
deployed builds regardless of what .dev.vars says.
Both workers must run simultaneously for cross-worker Durable Object bindings
(QUEST_ROOM, LOBBY_ROOM) to connect. Wrangler's local dev registry wires
them together automatically when both processes are up.
pnpm dev # Slack worker dev server
pnpm --filter web dev # web worker dev server (Vite + Wrangler) β run alongside pnpm devFor prod (CI handles this automatically β see below):
pnpm --filter web build # build the SPA into apps/web/dist
pnpm --filter web deploy # deploy the web worker (serves built assets)
pnpm deploy # deploy the Slack workerImportant: always build the web SPA before deploying the web worker. A
stale dist/ ships old UI with new server code β a frequent regression
source.
Two workflows in .github/workflows/ run automatically:
ci.ymlβ runspnpm test(Vitest) on every pull request. PRs must pass before merging.deploy.ymlβ triggers on every push tomain. Deploys the web worker first, then the Slack worker (order matters so DO classes exist before the Slack worker binds them). Wrangler configs are injected from GitHub Secrets (WRANGLER_WEB_CONFIG,WRANGLER_SLACK_CONFIG,CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID) β no config files are committed.
BOT_NAME env var (in apps/slack/wrangler.jsonc vars block, or via
npx wrangler secret put BOT_NAME). Defaults to "Gantt Quest". Used in
/sq help, /sq rules, and channel-restriction error messages.
pnpm test # one-shot Vitest across packages
pnpm test:watch # core package only, watch mode
pnpm typecheck # tsc across all packagesEngine tests (packages/core/src/*.test.ts) cover the deterministic math:
combat damage, dice rolls, drop tables, class lookup, scar generation, shop
pricing, status effect ticking, status proc rates, position modifiers,
critical hit rules, and the full step() reducer for each action kind.
Engine code is the source of truth β anything mechanical lives there and is covered by tests. Network / DO / AI code is intentionally untested; the engine surface is small enough that integration bugs are rare and obvious.
Icons come from game-icons.net β a CC BY 3.0 library of monochrome SVGs by Lorc, Delapouite, and others. They power the entire combat UI (weapon icons, status effect pills, slot indicators, class portraits' fallback glyphs, dungeon room markers, etc.).
The relevant set is gitignored under lib/icons/ per project convention;
to use a new icon, copy the SVG into apps/web/public/icons/ and register
the name in apps/web/src/icons.tsx.