Spaces:
Running
Running
Phase 0: progression state foundations (personaStore + progression util)
Browse filesExtend the soul schema with level/xp/kills/attributePoints, learnedSkills,
equippedSkills, customSkills, chat.history and an events log — default-filled on
read so the existing v1 roster migrates transparently. Add IndexedDB stores for
skill icons, skill action-art, and per-message chat audio. Add active-hero
selection and a merge-patch helper for hot writes (XP, chat turns). New
web/progression.js holds the XP curve + applyXp + event helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/models-to-support.md +68 -0
- docs/rpg-progression-plan.md +279 -0
- kimodo_pose.py +143 -0
- spaces/klein-zerogpu/app.py +22 -3
- web/personaStore.js +74 -9
- web/progression.js +72 -0
docs/models-to-support.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Prize:
|
| 2 |
+
|
| 3 |
+
Gradio Workflows
|
| 4 |
+
https://github.com/gradio-app/gradio/tree/main/demo/workflow
|
| 5 |
+
|
| 6 |
+
Image:
|
| 7 |
+
|
| 8 |
+
Flux 2 Klein 4B
|
| 9 |
+
https://huggingface.co/spaces/stephenbtl/klein-build-small-starter
|
| 10 |
+
https://huggingface.co/blog/black-forest-labs/flux-2-klein-lora
|
| 11 |
+
|
| 12 |
+
Voice:
|
| 13 |
+
|
| 14 |
+
openbmb/VoxCPM
|
| 15 |
+
nemotron 3.5 asr
|
| 16 |
+
|
| 17 |
+
Text Content / Reasoning / Tool use:
|
| 18 |
+
|
| 19 |
+
Nemotron 3 Nano 4b
|
| 20 |
+
openbmb/minicpm5-1b and 4.1-8b
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
Coding:
|
| 24 |
+
Nemotron 3 Nano 30b-a3b
|
| 25 |
+
nemotron cascade 2 30b-a3b
|
| 26 |
+
mellum2
|
| 27 |
+
|
| 28 |
+
multimodal:
|
| 29 |
+
minicpm-v-4.6 / minicpm
|
| 30 |
+
nemotron 3 nano omni 30b-a3b
|
| 31 |
+
|
| 32 |
+
docs / transcribe:
|
| 33 |
+
tiny-aya / cohere-transcribe
|
| 34 |
+
nemotron parse v1.2
|
| 35 |
+
|
| 36 |
+
safety:
|
| 37 |
+
nemotron 3.5 content safety
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
openbmb:
|
| 41 |
+
Hi everyone, I'm Zhong from OpenBMB! 👋
|
| 42 |
+
Would you like to win $10,000 OpenBMB Special Awards?
|
| 43 |
+
We're offering $10K in special prizes for the best projects built with MiniCPM models in the Build Small Hackathon, $5K for each track!
|
| 44 |
+
Whether you're participating in Backyard AI or Thousand Token Wood, using MiniCPM models can give you a strong edge.
|
| 45 |
+
Useful Resouces:
|
| 46 |
+
OpenBMB Model Collections: https://huggingface.co/openbmb/collections
|
| 47 |
+
OpenBMB GitHub : https://github.com/OpenBMB
|
| 48 |
+
OpenBMB X: https://x.com/OpenBMB
|
| 49 |
+
OpenBMB YouTube: https://www.youtube.com/channel/UCyDtv4OTmeZ1OYrCHymYGow
|
| 50 |
+
Eligible models:
|
| 51 |
+
MiniCPM5-1B — lightweight text/local
|
| 52 |
+
MiniCPM-V 4.6 — image/OCR/multimodal
|
| 53 |
+
MiniCPM-o 4.5 — omni-modal
|
| 54 |
+
MiniCPM4.1-8B — reasoning
|
| 55 |
+
VoxCPM2 — voice/TTS
|
| 56 |
+
Cookbooks:
|
| 57 |
+
MiniCPM-V / MiniCPM-o Cookbook —GitHub
|
| 58 |
+
MiniCPM-V / MiniCPM-o Cookbook — Website
|
| 59 |
+
MiniCPM-V Apps
|
| 60 |
+
|
| 61 |
+
MiniCPM-V 4.6 — image/OCR/multimodal
|
| 62 |
+
MiniCPM-o 4.5 — omni-modal
|
| 63 |
+
MiniCPM5-1B — lightweight text/local
|
| 64 |
+
MiniCPM4.1-8B — reasoning
|
| 65 |
+
VoxCPM2 — voice/TTS
|
| 66 |
+
|
| 67 |
+
https://huggingface.co/openbmb/MiniCPM5-1B
|
| 68 |
+
https://huggingface.co/blog/CohereLabs/build-small-hackathon-with-cohere-models
|
docs/rpg-progression-plan.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tiny Army — RPG Progression, Skill Forge, Character Chat & Voice
|
| 2 |
+
|
| 3 |
+
**Goal:** turn each fighter from a static persona into a living, evolving character that
|
| 4 |
+
**levels up**, **forges unique skills** (code + flavor + art), and you can **talk to**
|
| 5 |
+
on its character page and **hear it speak**. Mobile-first throughout.
|
| 6 |
+
|
| 7 |
+
This plan is grounded in the current architecture:
|
| 8 |
+
|
| 9 |
+
- **Souls persist client-side**: `web/personaStore.js` → `localStorage['tinyarmy.roster.v1']`
|
| 10 |
+
+ IndexedDB (`portraits`, `voices` blob stores). No backend DB; we extend this.
|
| 11 |
+
- **Combat is in the browser**: source in `../auto-battler/src/`, bundled into `web/*.js`
|
| 12 |
+
by `build.sh` (esbuild). Roam combat = `src/render/comboBattler.js`; resolver =
|
| 13 |
+
`src/engine/teamBattle.js`; skill IR = `src/engine/skills.js` (`CB_SKILLS`).
|
| 14 |
+
- **Skill IR is a clean object**: `{id, name, profession, attribute, category, target,
|
| 15 |
+
cost, cast, recharge, requires, effects[]}`, icon at `/gw/skills/<id>.jpg`. This is the
|
| 16 |
+
exact output contract for the Skill Forge model.
|
| 17 |
+
- **Models wired via facades**: text = `web/runtime.js` + `/text/generate/stream` (SSE);
|
| 18 |
+
image = `web/imagen.js` + `/portrait` (Klein on :7865); TTS = `web/tts.js`
|
| 19 |
+
(Kokoro in-browser / Qwen3 / VoxCPM). Code model = **BLS Mini-Code 30B** (the "skill forge").
|
| 20 |
+
- **Context pattern to copy from woid**: `agent-sandbox/pi-bridge/buildContext.js` —
|
| 21 |
+
static system prompt + dynamic per-turn delta (perception event log, needs/mood line,
|
| 22 |
+
verbatim memory of recent scenes). We port a single-player, client-side slice of this.
|
| 23 |
+
|
| 24 |
+
> ⚠️ Two-repo build: engine/skill changes are authored in `../auto-battler/src/**`, then
|
| 25 |
+
> `./build.sh` re-bundles into `tiny-army/web/`. UI shell + endpoints live in tiny-army
|
| 26 |
+
> (`app.py`, `web/*.js`). Verify on the auto-battler dev server (:5173) and the tiny-army
|
| 27 |
+
> server (:7860).
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Phase 0 — Foundations: persistent progression state
|
| 32 |
+
|
| 33 |
+
Everything else depends on the soul being able to *carry* progression. Do this first.
|
| 34 |
+
|
| 35 |
+
**0.1 Extend the persona schema** (`web/personaStore.js`)
|
| 36 |
+
Add, with a version bump + migration (default-fill on load so existing roster survives):
|
| 37 |
+
```js
|
| 38 |
+
progression: { level: 1, xp: 0, kills: 0, attributePoints: 0 },
|
| 39 |
+
learnedSkills: [], // skillIds owned by THIS character (refs into customSkills or CB_SKILLS)
|
| 40 |
+
equippedSkills: [], // up to 3 skillIds → the bar comboBattler reads
|
| 41 |
+
customSkills: {}, // skillId → forged skill IR + {flavor, iconKey, forgedFrom, forgedAt}
|
| 42 |
+
chat: { history: [] }, // [{ role:'user'|'char', text, ts, audioKey? }]
|
| 43 |
+
events: [], // append-only progression log (see Phase 3 memory)
|
| 44 |
+
```
|
| 45 |
+
New IndexedDB stores: `skillIcons` (PNG blobs, keyed by skillId), `chatAudio` (WAV blobs,
|
| 46 |
+
keyed by message id). Mirror the existing `portraits`/`voices` blob pattern.
|
| 47 |
+
|
| 48 |
+
**0.2 "Active hero" selection** — a small shared store so the character page, combat, and
|
| 49 |
+
chat all agree on *which* roster member is in play. Persist `tinyarmy.activeHeroId`.
|
| 50 |
+
|
| 51 |
+
**0.3 XP curve util** (`web/progression.js`) — pure functions:
|
| 52 |
+
`xpToNext(level)`, `applyXp(progression, amount) → {progression, leveledUp, levelsGained}`.
|
| 53 |
+
Single source of truth; no curve math scattered in UI.
|
| 54 |
+
|
| 55 |
+
**Acceptance:** existing roster loads unchanged; new fields present with defaults; refresh
|
| 56 |
+
preserves a hand-set `xp`/`level`.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Phase 1 — Leveling: defeat enemies → grow
|
| 61 |
+
|
| 62 |
+
**1.1 Emit a defeat event from combat** (`../auto-battler/src/render/comboBattler.js`)
|
| 63 |
+
The roam loop already tracks foe `.alive` and culls corpses via a `deadAt` map (~L372-377).
|
| 64 |
+
Add a one-shot hook when a foe transitions alive→dead *and* the player dealt the kill:
|
| 65 |
+
`opts.onEnemyDefeated?.({ id, name, enemyClass, x, y, t })`. Fire it once per foe at the
|
| 66 |
+
transition, before culling. Rebuild with `./build.sh`.
|
| 67 |
+
|
| 68 |
+
**1.2 Award XP in the shell** (`web/comboBattler.js` host / `web/tiny.js` wiring)
|
| 69 |
+
Pass `onEnemyDefeated` from tiny-army. On each defeat: look up enemy XP value
|
| 70 |
+
(small table by enemy class/tier), `applyXp` on the active hero, persist, and if
|
| 71 |
+
`leveledUp` push a `level_up` event + grant `attributePoints`.
|
| 72 |
+
|
| 73 |
+
**1.3 Level-up feedback (mobile)** — a lightweight toast/banner over the canvas
|
| 74 |
+
("⚔️ {name} reached level {n}!") + a dismissible level-up sheet offering: spend
|
| 75 |
+
attribute point, and (Phase 2) "Forge a new skill." Bottom-sheet style, thumb-reachable.
|
| 76 |
+
|
| 77 |
+
**1.4 Character page: progression header** (`web/heroCreator.js` barracks card / character sheet)
|
| 78 |
+
Show level, an XP progress bar, kill count, and attribute points available. Reuse existing
|
| 79 |
+
`.persona-*` CSS tokens; keep single-column on ≤768px.
|
| 80 |
+
|
| 81 |
+
**Acceptance:** killing foes in roam raises the active hero's XP bar; crossing a threshold
|
| 82 |
+
levels them up, persists across refresh, and grants a spendable point.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Phase 2 — Skill Forge: unique skills (code + flavor + art)
|
| 87 |
+
|
| 88 |
+
Each learned skill is **generated**, not picked from a list — unique IR from the code model,
|
| 89 |
+
flavor text from the text model, an icon from Klein.
|
| 90 |
+
|
| 91 |
+
**2.1 Skill IR validator** (`web/skillSchema.js`, mirrors `src/engine/skills.js`)
|
| 92 |
+
Whitelist of legal `category`, `target`, `effects[].op`, `condition`, and `cost` keys, with
|
| 93 |
+
numeric clamps (so a forged skill can't be game-breaking or crash the resolver). The engine
|
| 94 |
+
is the source of truth — keep the whitelist in sync with `CB_SKILLS` ops
|
| 95 |
+
(`apply_condition`, `bonus_damage`, `interrupt`, `lose_all_adrenaline`, …). Assign a fresh
|
| 96 |
+
non-colliding `id` (e.g. `9000+`) so icons/refs don't clash with the canon 0–~700 range.
|
| 97 |
+
|
| 98 |
+
**2.2 Forge endpoint** (`app.py` → `POST /skill/forge`, SSE)
|
| 99 |
+
Input: `{ heroId, persona, level, theme? }`. Pipeline:
|
| 100 |
+
1. **Code model (BLS Mini-Code 30B / `TINY_BLS_CODE_SPACE`)** — system prompt embeds the
|
| 101 |
+
skill IR spec + 2-3 `CB_SKILLS` examples; asked to emit **one JSON skill object** themed
|
| 102 |
+
to the character's class/persona/specialty. Strip the model's reasoning block (already
|
| 103 |
+
handled for BLS in `llm.py`). Validate via 2.1; **one retry** on invalid with the error
|
| 104 |
+
fed back; fall back to a templated mutation of a canon skill if still invalid (never block
|
| 105 |
+
a level-up on model failure).
|
| 106 |
+
2. **Text model** — short **flavor text** (1-2 sentences, in-world) for the skill, using the
|
| 107 |
+
persona's voice fields.
|
| 108 |
+
3. **Klein (`/portrait` reuse or `/skill-icon`)** — generate a square icon from a prompt
|
| 109 |
+
built off skill name + flavor + class palette; store blob in `skillIcons` IndexedDB.
|
| 110 |
+
Stream progress events (`forging-code`, `forging-flavor`, `forging-art`, `done`) so the UI
|
| 111 |
+
shows a live "forging…" state.
|
| 112 |
+
|
| 113 |
+
**2.3 Save + learn** — write the IR into `customSkills[id]`, push id to `learnedSkills`,
|
| 114 |
+
log a `skill_learned` event. Offer to **equip** (into the ≤3 `equippedSkills`). `comboBattler`
|
| 115 |
+
already reads `player.unit.skills` (L320) — feed it `equippedSkills` resolved against
|
| 116 |
+
`customSkills` ∪ `CB_SKILLS`.
|
| 117 |
+
|
| 118 |
+
**2.4 Two Klein renders per skill — icon + action shot.** The forge generates **two** images:
|
| 119 |
+
1. **Icon** — square, for the skill card (prompt: skill name + flavor + class palette).
|
| 120 |
+
2. **Action illustration** — a larger scene of **this character performing the skill**, built
|
| 121 |
+
from the persona's appearance/portrait fields + the skill's flavor + effect (e.g. "{name},
|
| 122 |
+
a {class}, mid-{skill} — {flavor}, motion + impact"). Stored in `skillIcons` (or a sibling
|
| 123 |
+
`skillArt`) IndexedDB store keyed by skillId. Generate the action shot lazily (on first
|
| 124 |
+
detail open) so forging stays fast, then cache.
|
| 125 |
+
|
| 126 |
+
**2.5 Character page: Skills tab + detail sheet (mobile)** — a grid of skill **cards** (icon,
|
| 127 |
+
name, compact effect summary via the engine's effect-label helper, e.g. `+bleeding`). Tap a
|
| 128 |
+
card → a **larger skill detail sheet**: the **action illustration** up top (the character
|
| 129 |
+
performing the skill), then name, full flavor text, effect breakdown, cost/attribute, and
|
| 130 |
+
equip/unequip (cap 3). Bottom-sheet style; single-column on phones; lazy-load art from IndexedDB.
|
| 131 |
+
|
| 132 |
+
**Acceptance:** on level-up you can forge a skill; it appears as a card with a unique Klein
|
| 133 |
+
icon + flavor; opening it shows a bigger detail screen with a Klein image of the character
|
| 134 |
+
performing the skill; equipping it makes it usable on the bar in roam combat; all survives refresh.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Phase 3 — Character Chat: talk to your fighter (woid-style context)
|
| 139 |
+
|
| 140 |
+
Port a **single-player, client-assembled** slice of woid's `buildContext.js`: a static
|
| 141 |
+
**system prompt** (identity) + a dynamic **state turn** ("what's happening to you now") +
|
| 142 |
+
**verbatim memory** of the recent conversation. No Colyseus/needs-decay complexity — just the
|
| 143 |
+
context-assembly shape.
|
| 144 |
+
|
| 145 |
+
**3.1 Context builder** (`web/characterContext.js`, modeled on woid `buildContext.js`)
|
| 146 |
+
- `buildSystemPrompt(persona)` — name, class, `about`, personality/vibe/specialty, plus a
|
| 147 |
+
"stay in voice, you are a real fighter who lived these events" rule. Built once per session.
|
| 148 |
+
- `buildStateTurn(persona, userMsg)` — the **delta**: current `level`, recent `events`
|
| 149 |
+
(battles won, skills forged, level-ups — woid's "perception log", here from `persona.events`),
|
| 150 |
+
equipped skills, last battle outcome / current HP if mid-roam. One compact block, like
|
| 151 |
+
woid's needs/mood/trigger lines.
|
| 152 |
+
- `buildMemory(persona.chat.history)` — verbatim last N turns (woid drops summarization;
|
| 153 |
+
we do too), char-budgeted.
|
| 154 |
+
|
| 155 |
+
**3.2 Chat endpoint** (`app.py` → `POST /chat/stream`, SSE)
|
| 156 |
+
Thin wrapper over the existing `/text/generate/stream` path (souls are client-side, so the
|
| 157 |
+
**client sends the assembled system + turn**; the server just streams the chosen text model —
|
| 158 |
+
Tiny Aya / MiniCPM / Mellum via `runtime.js`). Append the streamed reply to `chat.history`.
|
| 159 |
+
|
| 160 |
+
**3.3 Chat UI on the character screen (mobile)** — a message thread under the character
|
| 161 |
+
sheet: bubbles, a sticky bottom input + send (thumb zone), streamed tokens appear live (reuse
|
| 162 |
+
the SSE parser pattern from `personaStream.js`). Persist history in `chat.history`.
|
| 163 |
+
|
| 164 |
+
**Acceptance:** on a character's page you can ask "how was that last fight?" and get an
|
| 165 |
+
in-voice reply that references real state (their level, a skill they forged, the enemy they
|
| 166 |
+
just beat); the thread persists across refresh.
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## Phase 4 — Voice: hear them speak
|
| 171 |
+
|
| 172 |
+
**4.1 Per-message play button** — every character chat bubble gets a ▶ button. On tap, run
|
| 173 |
+
the existing TTS narrator (`web/tts.js`) with the persona's `voiceId`/`voice` design fields
|
| 174 |
+
(the same pipeline the diary + persona preview already use). Show a loading→playing→replay
|
| 175 |
+
state on the button.
|
| 176 |
+
|
| 177 |
+
**4.2 Cache audio** — store generated WAV in the `chatAudio` IndexedDB store keyed by message
|
| 178 |
+
id (mirror `voices`/`portraits`); replay is instant and offline. Skip regeneration if cached.
|
| 179 |
+
|
| 180 |
+
**4.3 (Optional) auto-speak toggle** — a per-character setting to auto-play replies as they
|
| 181 |
+
finish streaming.
|
| 182 |
+
|
| 183 |
+
**Acceptance:** tapping ▶ on a reply speaks it in the character's voice; replays from cache
|
| 184 |
+
with no re-synth; works on mobile (respects the tap-to-unlock-audio gesture).
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Phase 5 — Emotes: condition-driven character/enemy reactions
|
| 189 |
+
|
| 190 |
+
Heroes **and** enemies pop a little emote bubble above their sprite when something happens
|
| 191 |
+
(took a hit, killed a foe, low HP, afflicted, leveled, idle). Uses the **Minifantasy UI
|
| 192 |
+
Overhaul "Character_Emotions"** pack already in the repo — no model needed.
|
| 193 |
+
|
| 194 |
+
**Assets (already present in `../auto-battler/public/assets/.../Character_Emotions/`):**
|
| 195 |
+
- `_Emotions.png` (152×104) — a sheet of **8×8px** emoticon faces: angry, happy, sad,
|
| 196 |
+
surprised, dizzy/dazed, ❤ love, 💤 sleepy, ♪ music, sweat/fear, sick, etc.
|
| 197 |
+
- `_Bubble.png` (280×72) — speech-bubble frames with the tail pointing 8 directions, so the
|
| 198 |
+
bubble can sit above a sprite. (`Bubble_Only` / `Bubble_Outline` variants too.)
|
| 199 |
+
|
| 200 |
+
**5.1 Curate the pack into tiny-army** — add the emote + bubble PNGs to `curate_assets.py`'s
|
| 201 |
+
keep-list so `./build.sh` copies them into `web/assets/`. They're tiny (KBs).
|
| 202 |
+
|
| 203 |
+
**5.2 Emote sheet decoder** (`../auto-battler/src/render/emotes.js`, bundled to `web/`)
|
| 204 |
+
Decode `_Emotions.png` as an 8×8 grid → a named map (`{ angry, happy, sad, surprised, dizzy,
|
| 205 |
+
love, sleepy, music, sweat, sick, ... }`), and the bubble frame by tail direction. Mirror the
|
| 206 |
+
existing `spriteSheet.js`/`sheet.js` decode pattern (rows×cols, nearest-neighbor scale).
|
| 207 |
+
|
| 208 |
+
**5.3 Emote render layer** (`../auto-battler/src/render/comboBattler.js` + sprite scene)
|
| 209 |
+
A small overlay attached to each actor sprite: `showEmote(actorId, key, { ms })` spawns the
|
| 210 |
+
bubble + 8×8 icon above the sprite (scaled to match `groundScale`), auto-expires, and is
|
| 211 |
+
culled with the actor. Pixel-snapped, no blur. One emote at a time per actor (latest wins).
|
| 212 |
+
|
| 213 |
+
**5.4 Condition → emote mapping** (engine events → render)
|
| 214 |
+
Drive it off events the resolver/roam loop already produces (`teamBattle.js` logs hits,
|
| 215 |
+
conditions, deaths; `comboBattler` knows `.alive`/HP). A small, tunable table, e.g.:
|
| 216 |
+
|
| 217 |
+
| Trigger (condition) | Hero emote | Enemy emote |
|
| 218 |
+
|--------------------------------|-----------|-------------|
|
| 219 |
+
| Took a hit / `dmg` | 😠 angry | 😠 angry |
|
| 220 |
+
| Afflicted (`bleeding`/`poison`)| 😣 sick | 😣 sick |
|
| 221 |
+
| Stunned / `interrupt` / dazed | 😵 dizzy | 😵 dizzy |
|
| 222 |
+
| HP crosses low threshold | 😨 sweat | 😨 sweat |
|
| 223 |
+
| Killed a foe | 😄 happy | — |
|
| 224 |
+
| On death | — | 💀/😵 dizzy |
|
| 225 |
+
| Level-up (hero, Phase 1) | ❤ love | — |
|
| 226 |
+
| Idle a while / out of combat | 💤 sleepy | 💤 sleepy |
|
| 227 |
+
|
| 228 |
+
Debounce so a flurry of hits doesn't strobe (min interval per actor). Keep the table in one
|
| 229 |
+
place so it's easy to retune. Rebuild via `./build.sh`.
|
| 230 |
+
|
| 231 |
+
**5.5 (small) Settings toggle** — an "emotes" on/off in the existing settings; default on.
|
| 232 |
+
|
| 233 |
+
**Acceptance:** in roam combat, taking damage, getting bled/stunned, dropping low, and killing
|
| 234 |
+
foes each pop the right 8×8 emote bubble over the correct sprite (hero and enemies); a level-up
|
| 235 |
+
shows the ❤ emote; the bubble scales correctly on mobile and is pixel-crisp.
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## Cross-cutting
|
| 240 |
+
|
| 241 |
+
- **Mobile-first**: every new surface is single-column ≤768px, controls in the thumb zone,
|
| 242 |
+
bottom-sheets over modals, lazy-loaded media. Matches the existing joystick/attack mobile work.
|
| 243 |
+
- **Graceful degradation**: any model call (forge/chat/voice) can fail → always a non-blocking
|
| 244 |
+
fallback (templated skill, text-only reply, silent button). The app already degrades when
|
| 245 |
+
sidecars are down; preserve that.
|
| 246 |
+
- **Build discipline**: engine/skill edits → `../auto-battler/src` → `./build.sh` → verify.
|
| 247 |
+
Run local model sidecars with `./run_sidecars.sh` (Aya :7864, Klein :7865); BLS code model
|
| 248 |
+
via its space/sidecar.
|
| 249 |
+
- **Persistence is client-side** by design (hackathon). If cross-device sync is ever needed,
|
| 250 |
+
the one new thing required is a `/personas` GET/POST backend store — out of scope here.
|
| 251 |
+
|
| 252 |
+
## Dependency order
|
| 253 |
+
```
|
| 254 |
+
Phase 0 (state) ──► Phase 1 (leveling) ──► Phase 2 (skill forge + detail art)
|
| 255 |
+
└─► Phase 3 (chat) ──► Phase 4 (voice)
|
| 256 |
+
|
| 257 |
+
Phase 5 (emotes) ── needs only engine/render access; richer once Phase 1 events exist.
|
| 258 |
+
Can be built in parallel; level-up emote depends on Phase 1.
|
| 259 |
+
```
|
| 260 |
+
Phase 1, 3, and 5 can proceed in parallel once Phase 0 lands (chat doesn't need leveling but
|
| 261 |
+
reads richer state once leveling/forge exist; emotes are mostly self-contained render work).
|
| 262 |
+
|
| 263 |
+
## Model assignments
|
| 264 |
+
| Capability | Model | Path |
|
| 265 |
+
|-------------------|--------------------------------|------|
|
| 266 |
+
| Skill code (IR) | BLS Mini-Code 30B | `TINY_BLS_CODE_SPACE`, reasoning stripped |
|
| 267 |
+
| Skill flavor | Tiny Aya / MiniCPM / Mellum | `/text/generate/stream` |
|
| 268 |
+
| Skill icon + action shot | FLUX.2 Klein 4B | `/portrait` (:7865) — icon (square) + character-performing-skill illustration |
|
| 269 |
+
| Emote graphics | none (Minifantasy art pack) | `Character_Emotions/_Emotions.png` (8×8) + `_Bubble.png` |
|
| 270 |
+
| Character chat | Tiny Aya / MiniCPM / Mellum | `/chat/stream` → `/text/generate/stream` |
|
| 271 |
+
| Voice | Kokoro / Qwen3-TTS / VoxCPM | `web/tts.js` |
|
| 272 |
+
|
| 273 |
+
## Open decisions (pick before/while building)
|
| 274 |
+
1. **XP source**: kills-only (simplest), or also reward skill use / survival? → start kills-only.
|
| 275 |
+
2. **Forge trigger**: auto-offer one skill per level, or a spendable "forge token"? → token, so
|
| 276 |
+
forging is a deliberate, art-worthy moment.
|
| 277 |
+
3. **Equipped cap**: keep the engine's 3-skill bar, or expand? → keep 3 for balance + UI fit.
|
| 278 |
+
4. **Chat state breadth**: how much game state in the turn (just stats, or full recent event
|
| 279 |
+
log)? → start with stats + last 5 events; expand if replies feel thin.
|
kimodo_pose.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""kimodo motion -> OpenPose control image, for driving the weiner Klein LoRA.
|
| 3 |
+
|
| 4 |
+
The kimodo motion API (http://HOST:7862) returns, per clip, `posed_joints` [T,22,3]:
|
| 5 |
+
world-space 3D positions of the 22 SMPL-X body joints (y-up, metres). This module
|
| 6 |
+
projects a chosen frame to 2D and draws a canonical OpenPose (COCO-18) colored
|
| 7 |
+
skeleton — the abstract pose anchor that FLUX.2 Klein follows WITHOUT donating any
|
| 8 |
+
material (unlike a textured mannequin), so the weiner identity/LoRA supplies looks.
|
| 9 |
+
|
| 10 |
+
Pipeline: kimodo /animations/{id} -> openpose_png(frame) -> klein /generate
|
| 11 |
+
image=[skeleton, weiner_identity_shot], lora=weiner
|
| 12 |
+
|
| 13 |
+
CLI:
|
| 14 |
+
python kimodo_pose.py <anim_id> <out.png> [--frame N | --peak foot|hand] [--view front|side|auto]
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import urllib.request
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from PIL import Image, ImageDraw
|
| 24 |
+
|
| 25 |
+
KIMODO_URL = "http://127.0.0.1:7862"
|
| 26 |
+
|
| 27 |
+
# SMPL-X 22-joint order returned by the kimodo API (see /info bone_names).
|
| 28 |
+
SMPLX = {
|
| 29 |
+
"pelvis": 0, "left_hip": 1, "right_hip": 2, "spine1": 3, "left_knee": 4,
|
| 30 |
+
"right_knee": 5, "spine2": 6, "left_ankle": 7, "right_ankle": 8, "spine3": 9,
|
| 31 |
+
"left_foot": 10, "right_foot": 11, "neck": 12, "left_collar": 13,
|
| 32 |
+
"right_collar": 14, "head": 15, "left_shoulder": 16, "right_shoulder": 17,
|
| 33 |
+
"left_elbow": 18, "right_elbow": 19, "left_wrist": 20, "right_wrist": 21,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# OpenPose COCO-18 keypoint index -> source SMPL-X joint (eyes/ears synthesized below).
|
| 37 |
+
OP_FROM_SMPLX = {
|
| 38 |
+
0: "head", # nose ~ head
|
| 39 |
+
1: "neck", 2: "right_shoulder", 3: "right_elbow", 4: "right_wrist",
|
| 40 |
+
5: "left_shoulder", 6: "left_elbow", 7: "left_wrist",
|
| 41 |
+
8: "right_hip", 9: "right_knee", 10: "right_ankle",
|
| 42 |
+
11: "left_hip", 12: "left_knee", 13: "left_ankle",
|
| 43 |
+
}
|
| 44 |
+
# OpenPose limb pairs (COCO-18) + canonical colors (R,G,B).
|
| 45 |
+
OP_LIMBS = [
|
| 46 |
+
(1, 2, (255, 0, 0)), (1, 5, (255, 85, 0)), (2, 3, (255, 170, 0)),
|
| 47 |
+
(3, 4, (255, 255, 0)), (5, 6, (170, 255, 0)), (6, 7, (85, 255, 0)),
|
| 48 |
+
(1, 8, (0, 255, 0)), (8, 9, (0, 255, 85)), (9, 10, (0, 255, 170)),
|
| 49 |
+
(1, 11, (0, 255, 255)), (11, 12, (0, 170, 255)), (12, 13, (0, 85, 255)),
|
| 50 |
+
(1, 0, (0, 0, 255)), (0, 14, (85, 0, 255)), (14, 16, (170, 0, 255)),
|
| 51 |
+
(0, 15, (255, 0, 255)), (15, 17, (255, 0, 170)),
|
| 52 |
+
]
|
| 53 |
+
OP_POINT_COLOR = [
|
| 54 |
+
(255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255, 0),
|
| 55 |
+
(85, 255, 0), (0, 255, 0), (0, 255, 85), (0, 255, 170), (0, 255, 255),
|
| 56 |
+
(0, 170, 255), (0, 85, 255), (0, 0, 255), (85, 0, 255), (170, 0, 255),
|
| 57 |
+
(255, 0, 255), (255, 0, 170), (255, 0, 85),
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fetch(anim_id: str, url: str = KIMODO_URL) -> dict:
|
| 62 |
+
return json.load(urllib.request.urlopen(f"{url}/animations/{anim_id}", timeout=30))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def peak_frame(P: np.ndarray, what: str = "foot") -> int:
|
| 66 |
+
"""Frame where the action reads clearest: foot/hand at max height (kick/punch peak)."""
|
| 67 |
+
idx = [SMPLX["left_foot"], SMPLX["right_foot"], SMPLX["left_ankle"], SMPLX["right_ankle"]] \
|
| 68 |
+
if what == "foot" else [SMPLX["left_wrist"], SMPLX["right_wrist"]]
|
| 69 |
+
return int(P[:, idx, 1].max(axis=1).argmax())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _project(J: np.ndarray, view: str):
|
| 73 |
+
"""3D world joints [22,3] (y up) -> 2D [22,2] in image space. front: x-y; side: z-y."""
|
| 74 |
+
x, y, z = J[:, 0], J[:, 1], J[:, 2]
|
| 75 |
+
if view == "auto":
|
| 76 |
+
view = "side" if z.std() > x.std() * 1.15 else "front"
|
| 77 |
+
h = x if view == "front" else z
|
| 78 |
+
return np.stack([h, y], axis=1), view # (horizontal, vertical-up)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def openpose_image(P_frame: np.ndarray, size: int = 1024, view: str = "auto",
|
| 82 |
+
pad: float = 0.14) -> Image.Image:
|
| 83 |
+
"""Draw a COCO-18 OpenPose skeleton for one frame's [22,3] joints on black."""
|
| 84 |
+
pts2d, _ = _project(P_frame, view)
|
| 85 |
+
# build the 18 OpenPose keypoints
|
| 86 |
+
kp = np.full((18, 2), np.nan)
|
| 87 |
+
for op_i, name in OP_FROM_SMPLX.items():
|
| 88 |
+
kp[op_i] = pts2d[SMPLX[name]]
|
| 89 |
+
# synthesize eyes/ears around the head so the face limbs render
|
| 90 |
+
head, neck = pts2d[SMPLX["head"]], pts2d[SMPLX["neck"]]
|
| 91 |
+
up = (head - neck); span = np.linalg.norm(up) + 1e-6; up = up / span
|
| 92 |
+
side = np.array([-up[1], up[0]]) * span * 0.25
|
| 93 |
+
kp[0] = head + up * span * 0.15 # nose
|
| 94 |
+
kp[14], kp[15] = head + side * 0.6, head - side * 0.6 # R/L eye
|
| 95 |
+
kp[16], kp[17] = head + side, head - side # R/L ear
|
| 96 |
+
|
| 97 |
+
# normalize into the canvas (preserve aspect, feet at bottom, head at top)
|
| 98 |
+
valid = kp[~np.isnan(kp).any(axis=1)]
|
| 99 |
+
hmin, hmax = valid[:, 0].min(), valid[:, 0].max()
|
| 100 |
+
vmin, vmax = valid[:, 1].min(), valid[:, 1].max()
|
| 101 |
+
s = (1 - 2 * pad) * size / max(hmax - hmin, vmax - vmin, 1e-6)
|
| 102 |
+
cu = size / 2 - (hmin + hmax) / 2 * s
|
| 103 |
+
cv = size / 2 + (vmin + vmax) / 2 * s
|
| 104 |
+
|
| 105 |
+
def to_px(p):
|
| 106 |
+
return (cu + p[0] * s, cv - p[1] * s) # flip vertical (y-up -> image-down)
|
| 107 |
+
|
| 108 |
+
img = Image.new("RGB", (size, size), (0, 0, 0))
|
| 109 |
+
d = ImageDraw.Draw(img)
|
| 110 |
+
lw = max(4, size // 160)
|
| 111 |
+
for a, b, c in OP_LIMBS:
|
| 112 |
+
if np.isnan(kp[a]).any() or np.isnan(kp[b]).any():
|
| 113 |
+
continue
|
| 114 |
+
d.line([to_px(kp[a]), to_px(kp[b])], fill=c, width=lw)
|
| 115 |
+
r = max(4, size // 200)
|
| 116 |
+
for i, p in enumerate(kp):
|
| 117 |
+
if np.isnan(p).any():
|
| 118 |
+
continue
|
| 119 |
+
x, y = to_px(p)
|
| 120 |
+
d.ellipse([x - r, y - r, x + r, y + r], fill=OP_POINT_COLOR[i])
|
| 121 |
+
return img
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def render_anim_frame(anim_id, out, frame=None, peak="foot", view="auto", url=KIMODO_URL):
|
| 125 |
+
rec = fetch(anim_id, url)
|
| 126 |
+
P = np.array(rec["posed_joints"])
|
| 127 |
+
f = peak_frame(P, peak) if frame is None else int(frame)
|
| 128 |
+
img = openpose_image(P[f], view=view)
|
| 129 |
+
img.save(out)
|
| 130 |
+
print(f"saved {out} anim={anim_id} frame={f}/{P.shape[0]} view={view} prompt={rec.get('prompt','')[:60]!r}")
|
| 131 |
+
return f
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
ap = argparse.ArgumentParser()
|
| 136 |
+
ap.add_argument("anim_id")
|
| 137 |
+
ap.add_argument("out")
|
| 138 |
+
ap.add_argument("--frame", type=int, default=None)
|
| 139 |
+
ap.add_argument("--peak", choices=["foot", "hand"], default="foot")
|
| 140 |
+
ap.add_argument("--view", choices=["front", "side", "auto"], default="auto")
|
| 141 |
+
ap.add_argument("--url", default=KIMODO_URL)
|
| 142 |
+
a = ap.parse_args()
|
| 143 |
+
render_anim_frame(a.anim_id, a.out, a.frame, a.peak, a.view, a.url)
|
spaces/klein-zerogpu/app.py
CHANGED
|
@@ -46,6 +46,7 @@ def GPU(*dargs, **dkwargs): # noqa: N802
|
|
| 46 |
import gradio as gr
|
| 47 |
import torch
|
| 48 |
from diffusers import Flux2KleinPipeline
|
|
|
|
| 49 |
|
| 50 |
MODEL_ID = os.environ.get("TINY_KLEIN_MODEL", "black-forest-labs/FLUX.2-klein-4B")
|
| 51 |
STEPS = int(os.environ.get("TINY_KLEIN_STEPS", "4"))
|
|
@@ -118,8 +119,15 @@ if torch.cuda.is_available():
|
|
| 118 |
_offload_enabled = True
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
@GPU(duration=60)
|
| 122 |
-
def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA):
|
| 123 |
if not prompt or not prompt.strip():
|
| 124 |
raise gr.Error("prompt required")
|
| 125 |
_ensure_lora(lora)
|
|
@@ -128,14 +136,23 @@ def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA):
|
|
| 128 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 129 |
if seed is None or int(seed) < 0:
|
| 130 |
seed = random.randint(0, MAX_SEED)
|
| 131 |
-
|
| 132 |
prompt=prompt.strip(),
|
| 133 |
width=1024,
|
| 134 |
height=1024,
|
| 135 |
num_inference_steps=STEPS,
|
| 136 |
guidance_scale=GUIDANCE,
|
| 137 |
generator=torch.Generator(device=dev).manual_seed(int(seed)),
|
| 138 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
if dev == "cuda":
|
| 140 |
torch.cuda.empty_cache()
|
| 141 |
return img
|
|
@@ -148,6 +165,8 @@ demo = gr.Interface(
|
|
| 148 |
gr.Number(label="Seed", value=42, precision=0),
|
| 149 |
gr.Textbox(label="LoRA", value=DEFAULT_LORA,
|
| 150 |
placeholder="blank = none; weiner / weiner750 / weiner500 / weiner250"),
|
|
|
|
|
|
|
| 151 |
],
|
| 152 |
outputs=gr.Image(type="pil", label="Portrait"),
|
| 153 |
api_name="generate",
|
|
|
|
| 46 |
import gradio as gr
|
| 47 |
import torch
|
| 48 |
from diffusers import Flux2KleinPipeline
|
| 49 |
+
from PIL import Image
|
| 50 |
|
| 51 |
MODEL_ID = os.environ.get("TINY_KLEIN_MODEL", "black-forest-labs/FLUX.2-klein-4B")
|
| 52 |
STEPS = int(os.environ.get("TINY_KLEIN_STEPS", "4"))
|
|
|
|
| 119 |
_offload_enabled = True
|
| 120 |
|
| 121 |
|
| 122 |
+
def _to_img(x):
|
| 123 |
+
if x is None:
|
| 124 |
+
return None
|
| 125 |
+
img = x if isinstance(x, Image.Image) else Image.open(x)
|
| 126 |
+
return img.convert("RGB").resize((1024, 1024))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
@GPU(duration=60)
|
| 130 |
+
def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA, ref_image=None, ref_image2=None):
|
| 131 |
if not prompt or not prompt.strip():
|
| 132 |
raise gr.Error("prompt required")
|
| 133 |
_ensure_lora(lora)
|
|
|
|
| 136 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 137 |
if seed is None or int(seed) < 0:
|
| 138 |
seed = random.randint(0, MAX_SEED)
|
| 139 |
+
kwargs = dict(
|
| 140 |
prompt=prompt.strip(),
|
| 141 |
width=1024,
|
| 142 |
height=1024,
|
| 143 |
num_inference_steps=STEPS,
|
| 144 |
guidance_scale=GUIDANCE,
|
| 145 |
generator=torch.Generator(device=dev).manual_seed(int(seed)),
|
| 146 |
+
)
|
| 147 |
+
# FLUX.2 native multi-image conditioning: pass [pose anchor, identity ref] as a LIST so the
|
| 148 |
+
# composition follows one reference (e.g. a mannequin/depth render of a kimodo pose) while
|
| 149 |
+
# character identity comes from the other. One image -> passed alone (proven single-ref path).
|
| 150 |
+
refs = [r for r in (_to_img(ref_image), _to_img(ref_image2)) if r is not None]
|
| 151 |
+
if len(refs) == 1:
|
| 152 |
+
kwargs["image"] = refs[0]
|
| 153 |
+
elif refs:
|
| 154 |
+
kwargs["image"] = refs
|
| 155 |
+
img = pipe(**kwargs).images[0]
|
| 156 |
if dev == "cuda":
|
| 157 |
torch.cuda.empty_cache()
|
| 158 |
return img
|
|
|
|
| 165 |
gr.Number(label="Seed", value=42, precision=0),
|
| 166 |
gr.Textbox(label="LoRA", value=DEFAULT_LORA,
|
| 167 |
placeholder="blank = none; weiner / weiner750 / weiner500 / weiner250"),
|
| 168 |
+
gr.Image(label="Pose reference (optional)", type="pil"),
|
| 169 |
+
gr.Image(label="Identity reference (optional)", type="pil"),
|
| 170 |
],
|
| 171 |
outputs=gr.Image(type="pil", label="Portrait"),
|
| 172 |
api_name="generate",
|
web/personaStore.js
CHANGED
|
@@ -4,11 +4,32 @@
|
|
| 4 |
// Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio
|
| 5 |
// blob and cross-device sync are the backend's job (see the plan). Future persona data
|
| 6 |
// (stats, avatar, xp, relationships) just adds fields to the record.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const KEY = 'tinyarmy.roster.v1'
|
|
|
|
| 8 |
|
| 9 |
const listeners = new Set()
|
|
|
|
| 10 |
let _sync = null // optional { push(records), pull() } — wired to a backend later
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
function read() {
|
| 13 |
try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } }
|
| 14 |
catch { return { personas: [] } }
|
|
@@ -21,17 +42,21 @@ function write(d) {
|
|
| 21 |
|
| 22 |
const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
|
| 23 |
|
| 24 |
-
export function listPersonas() { return read().personas }
|
| 25 |
-
export function getPersona(id) {
|
| 26 |
export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
| 27 |
export function setSync(sync) { _sync = sync }
|
| 28 |
|
| 29 |
-
// Insert or update. Returns the stored record (with id + timestamps).
|
|
|
|
|
|
|
| 30 |
export function savePersona(p) {
|
| 31 |
const d = read()
|
| 32 |
const now = Date.now()
|
| 33 |
const id = p.id || newId()
|
| 34 |
-
const
|
|
|
|
|
|
|
| 35 |
id,
|
| 36 |
name: p.name || 'Unnamed hero',
|
| 37 |
unitClass: p.unitClass || '',
|
|
@@ -54,9 +79,16 @@ export function savePersona(p) {
|
|
| 54 |
// made from, so a reload knows the image is current vs stale (re-generate it).
|
| 55 |
appearance: p.appearance || '',
|
| 56 |
portraitUsed: p.portraitUsed || '',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
createdAt: now,
|
| 58 |
updatedAt: now,
|
| 59 |
-
}
|
| 60 |
const i = d.personas.findIndex((x) => x.id === id)
|
| 61 |
if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec }
|
| 62 |
else { d.personas.unshift(rec) }
|
|
@@ -64,24 +96,50 @@ export function savePersona(p) {
|
|
| 64 |
return rec
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
export function removePersona(id) {
|
| 68 |
const d = read()
|
| 69 |
const next = d.personas.filter((x) => x.id !== id)
|
| 70 |
if (next.length !== d.personas.length) write({ personas: next })
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
|
|
|
|
|
|
| 73 |
|
| 74 |
// ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
|
|
|
|
|
|
|
| 75 |
const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits'
|
|
|
|
| 76 |
let _dbp = null
|
| 77 |
function db() {
|
| 78 |
if (!_dbp) {
|
| 79 |
_dbp = new Promise((res, rej) => {
|
| 80 |
-
const r = indexedDB.open(DB,
|
| 81 |
r.onupgradeneeded = () => {
|
| 82 |
const d = r.result
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
}
|
| 86 |
r.onsuccess = () => res(r.result)
|
| 87 |
r.onerror = () => rej(r.error)
|
|
@@ -108,3 +166,10 @@ export const putAudio = (id, blob) => _put(STORE, id, blob)
|
|
| 108 |
export const getAudio = (id) => _get(STORE, id)
|
| 109 |
export const putPortrait = (id, blob) => _put(PSTORE, id, blob)
|
| 110 |
export const getPortrait = (id) => _get(PSTORE, id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
// Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio
|
| 5 |
// blob and cross-device sync are the backend's job (see the plan). Future persona data
|
| 6 |
// (stats, avatar, xp, relationships) just adds fields to the record.
|
| 7 |
+
//
|
| 8 |
+
// Progression (level/xp/kills, forged skills, chat history, an event log) is additive:
|
| 9 |
+
// `normalize()` default-fills the new fields on read so an existing v1 roster keeps
|
| 10 |
+
// working with no explicit migration step. See docs/rpg-progression-plan.md.
|
| 11 |
const KEY = 'tinyarmy.roster.v1'
|
| 12 |
+
const AKEY = 'tinyarmy.activeHeroId'
|
| 13 |
|
| 14 |
const listeners = new Set()
|
| 15 |
+
const activeListeners = new Set()
|
| 16 |
let _sync = null // optional { push(records), pull() } — wired to a backend later
|
| 17 |
|
| 18 |
+
// Default-fill the progression fields so old records (and partial saves) are always
|
| 19 |
+
// well-shaped. Additive only — never drops unknown fields.
|
| 20 |
+
function normalize(p) {
|
| 21 |
+
if (!p) return p
|
| 22 |
+
return {
|
| 23 |
+
...p,
|
| 24 |
+
progression: { level: 1, xp: 0, kills: 0, attributePoints: 0, ...(p.progression || {}) },
|
| 25 |
+
learnedSkills: Array.isArray(p.learnedSkills) ? p.learnedSkills : [],
|
| 26 |
+
equippedSkills: Array.isArray(p.equippedSkills) ? p.equippedSkills : [],
|
| 27 |
+
customSkills: p.customSkills && typeof p.customSkills === 'object' ? p.customSkills : {},
|
| 28 |
+
chat: { history: Array.isArray(p.chat?.history) ? p.chat.history : [] },
|
| 29 |
+
events: Array.isArray(p.events) ? p.events : [],
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
function read() {
|
| 34 |
try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } }
|
| 35 |
catch { return { personas: [] } }
|
|
|
|
| 42 |
|
| 43 |
const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
|
| 44 |
|
| 45 |
+
export function listPersonas() { return read().personas.map(normalize) }
|
| 46 |
+
export function getPersona(id) { const p = read().personas.find((x) => x.id === id); return p ? normalize(p) : null }
|
| 47 |
export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
| 48 |
export function setSync(sync) { _sync = sync }
|
| 49 |
|
| 50 |
+
// Insert or update. Returns the stored record (with id + timestamps). Progression fields
|
| 51 |
+
// are carried from `p` when present, else preserved from the existing record, else defaulted —
|
| 52 |
+
// so saving a persona edit never wipes its level/skills/chat.
|
| 53 |
export function savePersona(p) {
|
| 54 |
const d = read()
|
| 55 |
const now = Date.now()
|
| 56 |
const id = p.id || newId()
|
| 57 |
+
const prev = d.personas.find((x) => x.id === id) || {}
|
| 58 |
+
const pick = (k, dflt) => (p[k] !== undefined ? p[k] : (prev[k] !== undefined ? prev[k] : dflt))
|
| 59 |
+
const rec = normalize({
|
| 60 |
id,
|
| 61 |
name: p.name || 'Unnamed hero',
|
| 62 |
unitClass: p.unitClass || '',
|
|
|
|
| 79 |
// made from, so a reload knows the image is current vs stale (re-generate it).
|
| 80 |
appearance: p.appearance || '',
|
| 81 |
portraitUsed: p.portraitUsed || '',
|
| 82 |
+
// ── Progression (carried, not clobbered) ──
|
| 83 |
+
progression: pick('progression', undefined),
|
| 84 |
+
learnedSkills: pick('learnedSkills', undefined),
|
| 85 |
+
equippedSkills: pick('equippedSkills', undefined),
|
| 86 |
+
customSkills: pick('customSkills', undefined),
|
| 87 |
+
chat: pick('chat', undefined),
|
| 88 |
+
events: pick('events', undefined),
|
| 89 |
createdAt: now,
|
| 90 |
updatedAt: now,
|
| 91 |
+
})
|
| 92 |
const i = d.personas.findIndex((x) => x.id === id)
|
| 93 |
if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec }
|
| 94 |
else { d.personas.unshift(rec) }
|
|
|
|
| 96 |
return rec
|
| 97 |
}
|
| 98 |
|
| 99 |
+
// Shallow-merge a partial update into a stored persona and persist. Use this for hot,
|
| 100 |
+
// frequent writes (XP on a kill, a chat turn) so a partial update never rebuilds — and
|
| 101 |
+
// never drops — the rest of the record. Returns the updated record or null if not found.
|
| 102 |
+
export function patchPersona(id, partial) {
|
| 103 |
+
const d = read()
|
| 104 |
+
const i = d.personas.findIndex((x) => x.id === id)
|
| 105 |
+
if (i < 0) return null
|
| 106 |
+
d.personas[i] = normalize({ ...d.personas[i], ...partial, id, updatedAt: Date.now() })
|
| 107 |
+
write(d)
|
| 108 |
+
return d.personas[i]
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
export function removePersona(id) {
|
| 112 |
const d = read()
|
| 113 |
const next = d.personas.filter((x) => x.id !== id)
|
| 114 |
if (next.length !== d.personas.length) write({ personas: next })
|
| 115 |
+
if (getActiveHeroId() === id) setActiveHeroId(null)
|
| 116 |
+
for (const s of [STORE, PSTORE, ICONS, ART, CHATAUDIO]) _del(s, id)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// ── Active hero — the one roster member combat/chat/skills currently operate on ──
|
| 120 |
+
export function getActiveHeroId() { try { return localStorage.getItem(AKEY) || null } catch { return null } }
|
| 121 |
+
export function setActiveHeroId(id) {
|
| 122 |
+
try { id ? localStorage.setItem(AKEY, id) : localStorage.removeItem(AKEY) } catch { /* ignore */ }
|
| 123 |
+
for (const fn of activeListeners) { try { fn(id || null) } catch { /* ignore */ } }
|
| 124 |
}
|
| 125 |
+
export function getActiveHero() { const id = getActiveHeroId(); return id ? getPersona(id) : null }
|
| 126 |
+
export function onActiveHeroChange(fn) { activeListeners.add(fn); return () => activeListeners.delete(fn) }
|
| 127 |
|
| 128 |
// ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
|
| 129 |
+
// v3 adds skill icons, skill action-shot art, and per-message chat audio alongside
|
| 130 |
+
// the original voices + portraits stores.
|
| 131 |
const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits'
|
| 132 |
+
const ICONS = 'skillIcons', ART = 'skillArt', CHATAUDIO = 'chatAudio'
|
| 133 |
let _dbp = null
|
| 134 |
function db() {
|
| 135 |
if (!_dbp) {
|
| 136 |
_dbp = new Promise((res, rej) => {
|
| 137 |
+
const r = indexedDB.open(DB, 3)
|
| 138 |
r.onupgradeneeded = () => {
|
| 139 |
const d = r.result
|
| 140 |
+
for (const s of [STORE, PSTORE, ICONS, ART, CHATAUDIO]) {
|
| 141 |
+
if (!d.objectStoreNames.contains(s)) d.createObjectStore(s)
|
| 142 |
+
}
|
| 143 |
}
|
| 144 |
r.onsuccess = () => res(r.result)
|
| 145 |
r.onerror = () => rej(r.error)
|
|
|
|
| 166 |
export const getAudio = (id) => _get(STORE, id)
|
| 167 |
export const putPortrait = (id, blob) => _put(PSTORE, id, blob)
|
| 168 |
export const getPortrait = (id) => _get(PSTORE, id)
|
| 169 |
+
// Skill media is keyed by skillId; chat audio by message id.
|
| 170 |
+
export const putSkillIcon = (skillId, blob) => _put(ICONS, skillId, blob)
|
| 171 |
+
export const getSkillIcon = (skillId) => _get(ICONS, skillId)
|
| 172 |
+
export const putSkillArt = (skillId, blob) => _put(ART, skillId, blob)
|
| 173 |
+
export const getSkillArt = (skillId) => _get(ART, skillId)
|
| 174 |
+
export const putChatAudio = (msgId, blob) => _put(CHATAUDIO, msgId, blob)
|
| 175 |
+
export const getChatAudio = (msgId) => _get(CHATAUDIO, msgId)
|
web/progression.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Pure progression math + event helpers — the single source of truth for the XP curve
|
| 2 |
+
// so no UI re-derives it. See docs/rpg-progression-plan.md (Phase 0/1).
|
| 3 |
+
//
|
| 4 |
+
// Curve: a gentle quadratic so early levels come fast and later ones slow down.
|
| 5 |
+
// xpToNext(L) = BASE + GROWTH * (L-1)^1.5, rounded to a tidy step.
|
| 6 |
+
|
| 7 |
+
const BASE = 50
|
| 8 |
+
const GROWTH = 40
|
| 9 |
+
const ATTR_PER_LEVEL = 1
|
| 10 |
+
|
| 11 |
+
export function xpToNext(level) {
|
| 12 |
+
const l = Math.max(1, level | 0)
|
| 13 |
+
return Math.round((BASE + GROWTH * Math.pow(l - 1, 1.5)) / 10) * 10
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Total XP needed to have *reached* a given level from level 1 (handy for bars/among UIs).
|
| 17 |
+
export function xpForLevel(level) {
|
| 18 |
+
let sum = 0
|
| 19 |
+
for (let l = 1; l < Math.max(1, level | 0); l++) sum += xpToNext(l)
|
| 20 |
+
return sum
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Apply `amount` XP to a progression object, rolling over as many levels as the XP covers.
|
| 24 |
+
// Returns a NEW progression object plus what happened, so callers can react (toast, points).
|
| 25 |
+
export function applyXp(progression, amount) {
|
| 26 |
+
const p = { level: 1, xp: 0, kills: 0, attributePoints: 0, ...(progression || {}) }
|
| 27 |
+
let { level, xp, attributePoints } = p
|
| 28 |
+
xp += Math.max(0, amount | 0)
|
| 29 |
+
let leveledUp = false, levelsGained = 0
|
| 30 |
+
let need = xpToNext(level)
|
| 31 |
+
while (xp >= need) {
|
| 32 |
+
xp -= need
|
| 33 |
+
level += 1
|
| 34 |
+
levelsGained += 1
|
| 35 |
+
attributePoints += ATTR_PER_LEVEL
|
| 36 |
+
leveledUp = true
|
| 37 |
+
need = xpToNext(level)
|
| 38 |
+
}
|
| 39 |
+
return {
|
| 40 |
+
progression: { ...p, level, xp, attributePoints },
|
| 41 |
+
leveledUp,
|
| 42 |
+
levelsGained,
|
| 43 |
+
xpToNext: need,
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Fraction [0,1] toward the next level — for the XP bar.
|
| 48 |
+
export function levelProgress(progression) {
|
| 49 |
+
const p = progression || {}
|
| 50 |
+
const need = xpToNext(p.level || 1)
|
| 51 |
+
return need > 0 ? Math.min(1, Math.max(0, (p.xp || 0) / need)) : 0
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// XP a defeated foe is worth. Small table keyed by a coarse tier/class string; unknown
|
| 55 |
+
// enemies fall back to a sane default. Tunable in one place.
|
| 56 |
+
const ENEMY_XP = { weak: 8, normal: 12, tough: 20, elite: 40, boss: 100 }
|
| 57 |
+
export function enemyXpValue(enemy) {
|
| 58 |
+
if (!enemy) return ENEMY_XP.normal
|
| 59 |
+
if (typeof enemy.xp === 'number') return enemy.xp
|
| 60 |
+
const tier = (enemy.tier || enemy.rank || '').toLowerCase()
|
| 61 |
+
if (ENEMY_XP[tier]) return ENEMY_XP[tier]
|
| 62 |
+
return ENEMY_XP.normal
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Append a capped, structured event to a persona's log (newest last). Used for the chat
|
| 66 |
+
// "what's happening to you" context (woid-style perception log) and the level-up feed.
|
| 67 |
+
const MAX_EVENTS = 60
|
| 68 |
+
export function appendEvent(events, type, data = {}) {
|
| 69 |
+
const next = Array.isArray(events) ? events.slice() : []
|
| 70 |
+
next.push({ type, ...data })
|
| 71 |
+
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next
|
| 72 |
+
}
|