polats Claude Opus 4.8 (1M context) commited on
Commit
d0eb71c
·
1 Parent(s): c377173

Phase 0: progression state foundations (personaStore + progression util)

Browse files

Extend 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 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
- img = pipe(
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
- ).images[0]
 
 
 
 
 
 
 
 
 
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) { return read().personas.find((p) => p.id === id) || null }
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 rec = {
 
 
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
- _del(STORE, id); _del(PSTORE, id)
 
 
 
 
 
 
 
 
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, 2) // v2 adds the 'portraits' store alongside 'voices'
81
  r.onupgradeneeded = () => {
82
  const d = r.result
83
- if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
84
- if (!d.objectStoreNames.contains(PSTORE)) d.createObjectStore(PSTORE)
 
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
+ }