daydream / app /app.py
kaiser-data's picture
Real gameplay: story always proceeds + legible race
00b6404 verified
Raw
History Blame Contribute Delete
18.3 kB
"""DAYDREAM โ€” Press Your Luck, Keep Your Tiger (Gradio command deck).
You take GAMBITS โ€” safe/bold/reckless โ€” through dreamlike worlds. CODE rolls the
dice (visibly, seeded); a fleet of small models supplies the voices. Survive on
LUCIDITY, climb PROGRESS to wake with the prize, and watch your companion Hobbes
grow brave (COURAGE) because of the bets you take together. Win or lose, the run
freezes into a shareable Dream Journal card.
Run locally with no backend: DAYDREAM_MOCK=1 python app/app.py
Against Modal endpoints: set MODAL_* env (see .env.example), python app/app.py
"""
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import concurrent.futures # noqa: E402
import gradio as gr # noqa: E402
from agents.dream import DreamEngine # noqa: E402
from agents.world import ENVIRONMENTS, LUCIDITY_START, COURAGE_MAX, TIER_TABLE # noqa: E402
from agents.vision import dream_image # noqa: E402
engine = DreamEngine()
# Paint each dream beat off the main thread so the picture renders *under* the
# narration (same overlap trick as the Keeper) โ€” near-zero added latency per turn.
_IMG_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=2)
_CUR_IMG = None # the in-flight image future โ€” only the latest beat's image matters
def _submit_image(state, intent, outcome):
"""Start painting this beat; cancel any still-pending previous beat so a new
action always supersedes the old image instead of queueing behind it."""
global _CUR_IMG
if _CUR_IMG is not None:
_CUR_IMG.cancel() # drops it if not yet started; a running one is ignored below
_CUR_IMG = _IMG_POOL.submit(dream_image, state, intent, outcome)
return _CUR_IMG
# Ambient dream music: a static looping track played IN-BROWSER โ€” zero backend,
# zero latency, zero cold-start. Activates only if a (CC0/royalty-free) file is
# present, so the app never breaks without it. Drop one at assets/dream-ambient.mp3.
import os as _os # noqa: E402
_ASSETS = pathlib.Path(__file__).resolve().parent.parent / "assets"
MUSIC_PATH = str(_ASSETS / "dream-ambient.mp3")
HAS_MUSIC = _os.path.exists(MUSIC_PATH)
# Pre-generated world hero images shown the INSTANT you Begin โ€” so the world is
# never empty while the live per-beat image generates. Returned as a PIL image
# (not a path) so gr.Image embeds it directly โ€” a local filepath would hit
# Gradio's file-serving restrictions on the Space and render blank. Cached per id.
_HERO_CACHE: dict = {}
def _world_hero(env_id: str):
if env_id not in _HERO_CACHE:
p = _ASSETS / "worlds" / f"{env_id}.png"
img = None
if p.exists():
try:
from PIL import Image
img = Image.open(p).convert("RGB")
except Exception:
img = None
_HERO_CACHE[env_id] = img
return _HERO_CACHE[env_id]
SPEAKER = {
"Dreamweaver": "๐ŸŒŒ *Dreamweaver*",
"Nightmare": "๐Ÿ‘ *Nightmare*",
"Hobbes": "๐Ÿฏ **Hobbes**",
}
TIER_FACE = {"safe": "๐ŸŸข", "bold": "๐ŸŸก", "reckless": "๐Ÿ”ด"}
TIER_VARIANT = {"safe": "secondary", "bold": "primary", "reckless": "stop"}
MOOD_FACE = {"timid": "๐Ÿ™€", "warming": "๐Ÿ˜ผ", "brave": "๐Ÿ˜บ"}
# Hobbes' bond made visible turn-to-turn โ€” the emotional core of "Keep Your Tiger".
MOOD_PHRASE = {"timid": "cowering behind you",
"warming": "finding his courage",
"brave": "brave โ€” โ€œI've got youโ€"}
ENV_CHOICES = [(f"{e.emoji} {e.name}", k) for k, e in ENVIRONMENTS.items()]
NBTN = 3
def _meter(filled: int, total: int, on: str, off: str = "โฌœ") -> str:
n = max(0, min(total, filled))
return on * n + off * (total - n)
def state_md() -> str:
"""A focused HUD: the goal, three meters that matter, Hobbes' mood. Nothing else."""
s = engine.state
if s is None:
return "### ๐ŸŒ™ No dream yet\nPick a world and press **Begin the dream**."
luc = _meter(s.lucidity, LUCIDITY_START, "๐ŸŸฅ")
prog = _meter(s.progress // 10, 10, "๐ŸŸฆ")
cour = _meter(s.courage, COURAGE_MAX, "๐ŸŸจ")
parts = [
f"**๐ŸŽฏ {s.mission}**",
"<sub>๐Ÿ *Race: fill Progress to wake with the prize โ€” before Lucidity hits 0.*</sub>",
f"๐Ÿš€ **Progress to goal** {prog} `{s.progress}%`",
f"๐Ÿฉต **Lucidity** {luc} `{s.lucidity}/{LUCIDITY_START}`",
f"{MOOD_FACE[s.mood]} **Hobbes** {cour} *{MOOD_PHRASE[s.mood]}*",
]
if s.menace:
parts.append(f"๐Ÿ‘๏ธ **Nightmare** {'๐ŸŸช' * s.menace}"
+ (" **โ€” CLOSE! flee or face it**" if s.nightmare_near else ""))
if s.complete:
parts.append("โœจ **You wake with the prize.**")
elif s.lost:
parts.append("๐Ÿ’ค **Lost in the dream.**")
parts.append(f"<sub>๐Ÿ“ {s.location} ยท turn {s.turn} ยท seed `{s.seed}`</sub>")
return "\n\n".join(parts)
def card_md() -> str:
"""The shareable Dream Journal โ€” submission artifact + social post + beat-my-run."""
s = engine.state
if s is None or not s.over:
return ""
cause = ("woke with the prize ๐Ÿ†" if s.complete
else f"lost in the dream after {s.peak_menace} menace ๐Ÿ’ค")
items = ", ".join(s.inventory) if s.inventory else "nothing but the dream"
scars = ("; ".join(s.scars)) if s.scars else "no scars"
return (
f"## {s.emoji} DREAM JOURNAL โ€” {s.env_name}\n"
f"> *โ€œ{engine.farewell()}โ€* โ€” ๐Ÿฏ Hobbes\n\n"
f"- **Outcome:** {cause}\n"
f"- **Turns survived:** {s.turn} โ€ข **Progress:** {s.progress}%\n"
f"- **Peak menace:** {s.peak_menace} โ€ข **Final courage:** {s.courage}/{COURAGE_MAX} ({s.mood})\n"
f"- **Carried:** {items}\n"
f"- **Scars:** {scars}\n\n"
f"๐ŸŽฒ *Qwen narrating ยท MiniCPM keeping the world ยท code rolling the dice*\n\n"
f"**Beat my run โ†’ seed `{s.seed}` in {s.emoji} {s.env_name}**"
)
def build_story_md(history) -> str:
"""The player's full dream as a keepsake markdown โ€” every beat + the journal."""
s = engine.state
title = f"# ๐ŸŒ™ DAYDREAM โ€” {s.env_name if s else 'a dream'}\n"
meta = (f"*Seed `{s.seed}` ยท {s.turn} turns ยท "
f"dreamed by a fleet of small models*\n\n" if s else "")
out = [title, meta, "---\n"]
for m in (history or []):
c = (m.get("content") or "").strip()
if c:
out.append(c + "\n")
if s and s.over:
out.append("\n---\n\n" + card_md())
out.append("\n\n*Made with DAYDREAM ยท small models, big dreams ๐ŸŒ™*")
return "\n".join(out)
def download_story(history):
"""Write the story to a temp .md and hand the path to the DownloadButton."""
import tempfile
seed = engine.state.seed if engine.state else "dream"
path = pathlib.Path(tempfile.gettempdir()) / f"daydream-{seed}.md"
path.write_text(build_story_md(history), encoding="utf-8")
return str(path)
def _btn_updates():
ups = []
for i in range(NBTN):
if i < len(engine.gambits):
label, tier = engine.gambits[i]
spec = TIER_TABLE[tier]
fail = int(spec["fail_chance"] * 100)
reward = int(spec["progress_reward"])
cost = int(spec["lucidity_cost"])
ups.append(gr.update(value=f"{TIER_FACE[tier]} {label} ยท {fail}% fail ยท +{reward}/-{cost}",
variant=TIER_VARIANT[tier], visible=True))
else:
ups.append(gr.update(visible=False))
return ups
def _hidden_btns():
return [gr.update(visible=False) for _ in range(NBTN)]
def _card_updates():
"""(card markdown, card visibility, print-button visibility)."""
over = engine.state is not None and engine.state.over
return gr.update(value=card_md(), visible=over), gr.update(visible=over)
def _die_bubble() -> dict:
"""Plain-language dice: roll high to win. Shows the bands so it's learnable."""
o = engine.last_outcome
f = o.fail_threshold
line = {"success": "โœจ **Success!**", "partial": "ใ€ฐ **Partial** โ€” it half-works",
"fail": "๐Ÿ’ฅ **Failed**"}[o.result]
return {"role": "assistant",
"content": (f"๐ŸŽฒ You rolled **{o.roll}** / 100 โ€” {line} \n"
f"<sub>higher is better ยท 1โ€“{f} fails ยท {f+1}โ€“{f+20} partial ยท "
f"{f+21}+ wins ยท ({o.tier} gambit)</sub>")}
def _stream_turn(intent, tier, history):
"""Shared streaming loop -> [chatbot, state, btn1..N, intent, card, printbtn, dream]."""
history = history or []
history.append({"role": "user", "content": f"๐Ÿง‘โ€๐Ÿš€ {intent}"})
# Keep the gambit buttons VISIBLE and stable through the whole turn (no flicker).
# Gradio queues clicks, so a tap mid-stream just runs as the next turn.
yield history, state_md(), *_btn_updates(), "", *_card_updates(), gr.update()
current = None
die_shown = False
img_future = None
for speaker, delta in engine.play(intent, tier):
if img_future is None:
# Outcome is decided by now (None on the arrival beat). Paint this beat in
# a thread (cancelling any stale previous beat) so the picture renders
# *under* the prose and a new action always supersedes the old image.
img_future = _submit_image(engine.state, intent, engine.last_outcome)
if not die_shown and engine.last_outcome is not None:
die_shown = True # reveal the seeded roll the instant it's decided
history.append(_die_bubble())
yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update()
if speaker != current:
current = speaker
history.append({"role": "assistant", "content": SPEAKER.get(speaker, speaker) + ": "})
history[-1]["content"] += delta
yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update()
# Show the gambit buttons the INSTANT narration + Hobbes are done โ€” don't make
# the player wait on the slower image. (Buttons in ~8s, not ~17s.)
yield history, state_md(), *_btn_updates(), "", *_card_updates(), gr.update()
# Then let the dream image pop in when it's ready (often already painted under
# the prose; warm FLUX is ~1s). Don't hang the turn on a slow/cold image, and
# only show it if THIS beat is still the latest request โ€” a newer action wins.
if img_future is not None:
try:
pic = img_future.result(timeout=30)
except Exception:
pic = None
if pic is not None and img_future is _CUR_IMG:
yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update(value=pic, visible=True)
def begin(env_id, seed, history):
engine.start(env_id, seed=(seed or "dream").strip())
env = ENVIRONMENTS[env_id]
history = [{"role": "assistant", "content": f"{env.emoji} *{env.opening}*"},
{"role": "assistant", "content": f"๐ŸŽฏ **Quest:** {env.mission}"}]
# Show the world's hero image + three gambit options INSTANTLY, so the world is
# never empty and you always know what to do. The live narration, Hobbes' own
# creative labels, and the per-beat image all layer in over the next few seconds.
engine.gambits = engine._make_gambits([]) # default labels, upgraded by Hobbes
hero = _world_hero(env_id)
hero_up = (gr.update(value=hero, visible=True) if hero
else gr.update(value=None, visible=False))
yield history, state_md(), *_btn_updates(), "", *_card_updates(), hero_up
yield from _stream_turn("(You arrive and take in the scene.)", None, history)
def take_turn(intent, history):
if engine.state is None:
yield history or [], "### ๐ŸŒ™ Pick a world and begin first.", *_hidden_btns(), "", *_card_updates(), gr.update()
return
if engine.state.over or not (intent or "").strip():
yield history or [], state_md(), *_btn_updates(), "", *_card_updates(), gr.update()
return
yield from _stream_turn(intent.strip(), "bold", history) # typed = a bold gamble
def make_choose(i):
def _choose(history):
if engine.state is not None and not engine.state.over and i < len(engine.gambits):
label, tier = engine.gambits[i]
yield from _stream_turn(label, tier, history)
else:
yield history or [], state_md(), *_btn_updates(), "", *_card_updates(), gr.update()
return _choose
CSS = """
/* --- dream atmosphere: layered nebula + slow drift, pure CSS, no JS --- */
.gradio-container {
background:
radial-gradient(900px 500px at 15% -10%, rgba(124,92,255,.28), transparent 60%),
radial-gradient(1000px 600px at 85% 0%, rgba(56,120,200,.22), transparent 55%),
radial-gradient(1200px 800px at 50% 120%, rgba(180,80,200,.16), transparent 60%),
#0b0a1c;
background-attachment: fixed;
}
.gradio-container::before { /* drifting star-dust */
content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .5; z-index: 0;
background-image:
radial-gradient(1.5px 1.5px at 20% 30%, #fff, transparent),
radial-gradient(1.5px 1.5px at 70% 60%, #cdbcff, transparent),
radial-gradient(1px 1px at 40% 80%, #fff, transparent),
radial-gradient(1px 1px at 85% 25%, #bcd4ff, transparent),
radial-gradient(1.5px 1.5px at 55% 15%, #fff, transparent);
background-size: 600px 600px; animation: drift 90s linear infinite;
}
@keyframes drift { from {background-position: 0 0;} to {background-position: 600px 600px;} }
h1 { letter-spacing: .5px; text-shadow: 0 2px 24px rgba(124,92,255,.5); }
#deck {border-radius: 16px; backdrop-filter: blur(2px);
box-shadow: 0 10px 40px rgba(0,0,0,.35), inset 0 0 0 1px rgba(124,92,255,.18);}
#card {border: 1px solid #7c5cff; border-radius: 14px; padding: 8px 16px;
background: rgba(40,30,80,.45);}
/* the dream image is the HERO โ€” large, glowing, gently breathing */
#dream {border-radius: 16px; border: 1px solid rgba(124,92,255,.6); overflow: hidden;
box-shadow: 0 12px 50px rgba(124,92,255,.45); animation: breathe 7s ease-in-out infinite;}
#dream img {border-radius: 14px;}
@keyframes breathe { 0%,100% {box-shadow: 0 12px 50px rgba(124,92,255,.35);}
50% {box-shadow: 0 16px 64px rgba(124,92,255,.6);} }
/* warm-up banner shimmers so the cold-start feels like the dream forming */
#warmup {border-left: 3px solid #7c5cff; padding: 4px 14px; margin: -4px 0 6px;
border-radius: 10px; font-size: .92em; position: relative; overflow: hidden;
background: linear-gradient(90deg, rgba(124,92,255,.10), rgba(124,92,255,.22), rgba(124,92,255,.10));
background-size: 200% 100%; animation: shimmer 3.5s ease-in-out infinite;}
@keyframes shimmer { 0% {background-position: 200% 0;} 100% {background-position: -200% 0;} }
.gr-button-primary { box-shadow: 0 6px 24px rgba(124,92,255,.4); }
/* fleet line โ€” a single subtle credit strip, not a competing banner */
#fleet {padding: 2px 8px; margin: -4px 0 2px; opacity: .72; text-align: center;}
#fleet sub {font-size: .82em;}
footer {visibility: hidden;}
"""
with gr.Blocks(title="DAYDREAM") as demo:
gr.Markdown(
"# ๐ŸŒ™ DAYDREAM\n"
"*A dream you **play** โ€” chase the quest, keep your tiger brave, press your luck.*"
)
gr.Markdown(
"<sub>๐Ÿค– dreamed by a fleet: ๐ŸŒŒ๐Ÿ‘๐Ÿฏ **Qwen3-30B-A3B** (MoE ยท 3B active) ยท "
"๐Ÿ—บ **MiniCPM-1B** ยท ๐ŸŽจ **FLUX** โ€” every model โ‰ค32B &nbsp;ยท&nbsp; "
"โณ first turn wakes the GPUs (~1โ€“2 min), then ~5s each</sub>",
elem_id="fleet",
)
with gr.Row():
with gr.Column(scale=3):
chat = gr.Chatbot(height=440, show_label=False, elem_id="deck")
with gr.Row():
btns = [gr.Button(visible=False, size="sm") for _ in range(NBTN)]
with gr.Row():
intent = gr.Textbox(
placeholder="โ€ฆor type your own move โ€” e.g. โ€œdig where the sand humsโ€ "
"or โ€œfollow the strange tracksโ€ (counts as a bold gamble)",
scale=8, show_label=False, autofocus=True)
go = gr.Button("Do it", variant="primary", scale=1)
card = gr.Markdown(visible=False, elem_id="card")
with gr.Row():
printbtn = gr.Button("๐Ÿ“ธ Freeze the dream card", visible=False)
dlbtn = gr.DownloadButton("๐Ÿ“ฅ Download my story", size="sm")
with gr.Column(scale=2):
# Show the chosen world's hero image immediately (and on selection) so the
# frame is never empty โ€” even while the first turn cold-starts.
dream_img = gr.Image(value=_world_hero("candy_desert"), label="๐ŸŒŒ The dream",
height=380, visible=True, interactive=False,
elem_id="dream", show_label=False)
world = gr.Dropdown(ENV_CHOICES, value="candy_desert", label="World")
seed = gr.Textbox(value="abc123", label="Seed (shareable)")
start = gr.Button("Begin the dream ๐ŸŒ™", variant="primary")
panel = gr.Markdown(state_md())
if HAS_MUSIC: # looping ambient track; play once, it loops the session
gr.Audio(MUSIC_PATH, loop=True, autoplay=False,
label="๐ŸŽต Dream music", elem_id="music")
# Show the picked world's hero image immediately on selection (before Begin),
# so the frame previews the world and is never empty.
world.change(lambda env_id: gr.update(value=_world_hero(env_id), visible=True),
world, dream_img)
outs = [chat, panel, *btns, intent, card, printbtn, dream_img]
start.click(begin, [world, seed, chat], outs)
go.click(take_turn, [intent, chat], outs)
intent.submit(take_turn, [intent, chat], outs)
for i, b in enumerate(btns):
b.click(make_choose(i), [chat], outs)
printbtn.click(lambda: gr.update(visible=True), None, card)
dlbtn.click(download_story, [chat], dlbtn) # export the full dream as markdown
if __name__ == "__main__":
demo.launch(theme=gr.themes.Soft(primary_hue="indigo"), css=CSS)