File size: 18,677 Bytes
d0eb71c
 
688ffc2
 
 
 
3e1a2a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688ffc2
d0eb71c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# Tiny Army — RPG Progression, Skill Forge, Character Chat & Voice

> **Status (2026-06-13): all 6 phases implemented & committed** on branch `local`
> (tiny-army) + `local` (auto-battler). Phase 0 state · Phase 1 leveling · Phase 2
> skill forge + detail art · Phase 3 chat · Phase 4 voice · Phase 5 emotes. Each
> verified in-browser (logic + mount + live sidecar round-trip). Not yet pushed.
>
> **Fun + living-world milestone (2026-06-13, also committed, not pushed):**
> The core game loop was shallow, so a second pass made combat actually fun and
> wove in more load-bearing LLM:
> - **Leveling scales power** — level+spent points → rank/HP/damage (`statBonuses`); a
>   level-up gives an immediate live buff + full heal; attribute points spendable (+HP/+DMG).
> - **Escalating waves** — foes ramp with time+kills; Elite (2.4× HP) and Champion boss
>   (4.6× HP, bigger sprite) on kill milestones, worth far more XP.
> - **Hit juice** — hitstop + screenshake (processLog onHit/onDeath hooks); existing red flash.
> - **Dodge roll** — Shift / ⟲ button → fast i-frame dash (engine `invulnUntil` in dealDamage).
> - **Combat HUD** — adrenaline meter, per-skill ready-dim, top threat badge (surfaces the
>   engine's hidden adrenaline/condition/combo depth).
> - **Live combat state in chat** — `buildStateTurn` takes a live snapshot (HP/threat) so a
>   fighter answers aware of its current wounds, not just persisted history.
> - **Death after-action + war-diary recap** — on death, a report (kills/threat/levels/time)
>   + an in-voice LLM recap of the run, banked to the hero's memory (feeds chat).
> - **Personal-best records + shared Chronicle** — a record to beat per hero; fallen heroes'
>   legends post to a world feed shown on the after-action (the world feels populated).
>
> Still open (good next swings): loot/rewards, the bigger living-world LLM features
> (social fabric/relationships, off-screen evolution, skill+soul mutation, an LLM director).

**Goal:** turn each fighter from a static persona into a living, evolving character that
**levels up**, **forges unique skills** (code + flavor + art), and you can **talk to**
on its character page and **hear it speak**. Mobile-first throughout.

This plan is grounded in the current architecture:

- **Souls persist client-side**: `web/personaStore.js``localStorage['tinyarmy.roster.v1']`
  + IndexedDB (`portraits`, `voices` blob stores). No backend DB; we extend this.
- **Combat is in the browser**: source in `../auto-battler/src/`, bundled into `web/*.js`
  by `build.sh` (esbuild). Roam combat = `src/render/comboBattler.js`; resolver =
  `src/engine/teamBattle.js`; skill IR = `src/engine/skills.js` (`CB_SKILLS`).
- **Skill IR is a clean object**: `{id, name, profession, attribute, category, target,
  cost, cast, recharge, requires, effects[]}`, icon at `/gw/skills/<id>.jpg`. This is the
  exact output contract for the Skill Forge model.
- **Models wired via facades**: text = `web/runtime.js` + `/text/generate/stream` (SSE);
  image = `web/imagen.js` + `/portrait` (Klein on :7865); TTS = `web/tts.js`
  (Kokoro in-browser / Qwen3 / VoxCPM). Code model = **BLS Mini-Code 30B** (the "skill forge").
- **Context pattern to copy from woid**: `agent-sandbox/pi-bridge/buildContext.js` —
  static system prompt + dynamic per-turn delta (perception event log, needs/mood line,
  verbatim memory of recent scenes). We port a single-player, client-side slice of this.

> ⚠️ Two-repo build: engine/skill changes are authored in `../auto-battler/src/**`, then
> `./build.sh` re-bundles into `tiny-army/web/`. UI shell + endpoints live in tiny-army
> (`app.py`, `web/*.js`). Verify on the auto-battler dev server (:5173) and the tiny-army
> server (:7860).

---

## Phase 0 — Foundations: persistent progression state

Everything else depends on the soul being able to *carry* progression. Do this first.

**0.1 Extend the persona schema** (`web/personaStore.js`)
Add, with a version bump + migration (default-fill on load so existing roster survives):
```js
progression: { level: 1, xp: 0, kills: 0, attributePoints: 0 },
learnedSkills: [],        // skillIds owned by THIS character (refs into customSkills or CB_SKILLS)
equippedSkills: [],       // up to 3 skillIds → the bar comboBattler reads
customSkills: {},         // skillId → forged skill IR + {flavor, iconKey, forgedFrom, forgedAt}
chat: { history: [] },    // [{ role:'user'|'char', text, ts, audioKey? }]
events: [],               // append-only progression log (see Phase 3 memory)
```
New IndexedDB stores: `skillIcons` (PNG blobs, keyed by skillId), `chatAudio` (WAV blobs,
keyed by message id). Mirror the existing `portraits`/`voices` blob pattern.

**0.2 "Active hero" selection** — a small shared store so the character page, combat, and
chat all agree on *which* roster member is in play. Persist `tinyarmy.activeHeroId`.

**0.3 XP curve util** (`web/progression.js`) — pure functions:
`xpToNext(level)`, `applyXp(progression, amount) → {progression, leveledUp, levelsGained}`.
Single source of truth; no curve math scattered in UI.

**Acceptance:** existing roster loads unchanged; new fields present with defaults; refresh
preserves a hand-set `xp`/`level`.

---

## Phase 1 — Leveling: defeat enemies → grow

**1.1 Emit a defeat event from combat** (`../auto-battler/src/render/comboBattler.js`)
The roam loop already tracks foe `.alive` and culls corpses via a `deadAt` map (~L372-377).
Add a one-shot hook when a foe transitions alive→dead *and* the player dealt the kill:
`opts.onEnemyDefeated?.({ id, name, enemyClass, x, y, t })`. Fire it once per foe at the
transition, before culling. Rebuild with `./build.sh`.

**1.2 Award XP in the shell** (`web/comboBattler.js` host / `web/tiny.js` wiring)
Pass `onEnemyDefeated` from tiny-army. On each defeat: look up enemy XP value
(small table by enemy class/tier), `applyXp` on the active hero, persist, and if
`leveledUp` push a `level_up` event + grant `attributePoints`.

**1.3 Level-up feedback (mobile)** — a lightweight toast/banner over the canvas
("⚔️ {name} reached level {n}!") + a dismissible level-up sheet offering: spend
attribute point, and (Phase 2) "Forge a new skill." Bottom-sheet style, thumb-reachable.

**1.4 Character page: progression header** (`web/heroCreator.js` barracks card / character sheet)
Show level, an XP progress bar, kill count, and attribute points available. Reuse existing
`.persona-*` CSS tokens; keep single-column on ≤768px.

**Acceptance:** killing foes in roam raises the active hero's XP bar; crossing a threshold
levels them up, persists across refresh, and grants a spendable point.

---

## Phase 2 — Skill Forge: unique skills (code + flavor + art)

Each learned skill is **generated**, not picked from a list — unique IR from the code model,
flavor text from the text model, an icon from Klein.

**2.1 Skill IR validator** (`web/skillSchema.js`, mirrors `src/engine/skills.js`)
Whitelist of legal `category`, `target`, `effects[].op`, `condition`, and `cost` keys, with
numeric clamps (so a forged skill can't be game-breaking or crash the resolver). The engine
is the source of truth — keep the whitelist in sync with `CB_SKILLS` ops
(`apply_condition`, `bonus_damage`, `interrupt`, `lose_all_adrenaline`, …). Assign a fresh
non-colliding `id` (e.g. `9000+`) so icons/refs don't clash with the canon 0–~700 range.

**2.2 Forge endpoint** (`app.py``POST /skill/forge`, SSE)
Input: `{ heroId, persona, level, theme? }`. Pipeline:
1. **Code model (BLS Mini-Code 30B / `TINY_BLS_CODE_SPACE`)** — system prompt embeds the
   skill IR spec + 2-3 `CB_SKILLS` examples; asked to emit **one JSON skill object** themed
   to the character's class/persona/specialty. Strip the model's reasoning block (already
   handled for BLS in `llm.py`). Validate via 2.1; **one retry** on invalid with the error
   fed back; fall back to a templated mutation of a canon skill if still invalid (never block
   a level-up on model failure).
2. **Text model** — short **flavor text** (1-2 sentences, in-world) for the skill, using the
   persona's voice fields.
3. **Klein (`/portrait` reuse or `/skill-icon`)** — generate a square icon from a prompt
   built off skill name + flavor + class palette; store blob in `skillIcons` IndexedDB.
Stream progress events (`forging-code`, `forging-flavor`, `forging-art`, `done`) so the UI
shows a live "forging…" state.

**2.3 Save + learn** — write the IR into `customSkills[id]`, push id to `learnedSkills`,
log a `skill_learned` event. Offer to **equip** (into the ≤3 `equippedSkills`). `comboBattler`
already reads `player.unit.skills` (L320) — feed it `equippedSkills` resolved against
`customSkills``CB_SKILLS`.

**2.4 Two Klein renders per skill — icon + action shot.** The forge generates **two** images:
1. **Icon** — square, for the skill card (prompt: skill name + flavor + class palette).
2. **Action illustration** — a larger scene of **this character performing the skill**, built
   from the persona's appearance/portrait fields + the skill's flavor + effect (e.g. "{name},
   a {class}, mid-{skill} — {flavor}, motion + impact"). Stored in `skillIcons` (or a sibling
   `skillArt`) IndexedDB store keyed by skillId. Generate the action shot lazily (on first
   detail open) so forging stays fast, then cache.

**2.5 Character page: Skills tab + detail sheet (mobile)** — a grid of skill **cards** (icon,
name, compact effect summary via the engine's effect-label helper, e.g. `+bleeding`). Tap a
card → a **larger skill detail sheet**: the **action illustration** up top (the character
performing the skill), then name, full flavor text, effect breakdown, cost/attribute, and
equip/unequip (cap 3). Bottom-sheet style; single-column on phones; lazy-load art from IndexedDB.

**Acceptance:** on level-up you can forge a skill; it appears as a card with a unique Klein
icon + flavor; opening it shows a bigger detail screen with a Klein image of the character
performing the skill; equipping it makes it usable on the bar in roam combat; all survives refresh.

---

## Phase 3 — Character Chat: talk to your fighter (woid-style context)

Port a **single-player, client-assembled** slice of woid's `buildContext.js`: a static
**system prompt** (identity) + a dynamic **state turn** ("what's happening to you now") +
**verbatim memory** of the recent conversation. No Colyseus/needs-decay complexity — just the
context-assembly shape.

**3.1 Context builder** (`web/characterContext.js`, modeled on woid `buildContext.js`)
- `buildSystemPrompt(persona)` — name, class, `about`, personality/vibe/specialty, plus a
  "stay in voice, you are a real fighter who lived these events" rule. Built once per session.
- `buildStateTurn(persona, userMsg)` — the **delta**: current `level`, recent `events`
  (battles won, skills forged, level-ups — woid's "perception log", here from `persona.events`),
  equipped skills, last battle outcome / current HP if mid-roam. One compact block, like
  woid's needs/mood/trigger lines.
- `buildMemory(persona.chat.history)` — verbatim last N turns (woid drops summarization;
  we do too), char-budgeted.

**3.2 Chat endpoint** (`app.py``POST /chat/stream`, SSE)
Thin wrapper over the existing `/text/generate/stream` path (souls are client-side, so the
**client sends the assembled system + turn**; the server just streams the chosen text model —
Tiny Aya / MiniCPM / Mellum via `runtime.js`). Append the streamed reply to `chat.history`.

**3.3 Chat UI on the character screen (mobile)** — a message thread under the character
sheet: bubbles, a sticky bottom input + send (thumb zone), streamed tokens appear live (reuse
the SSE parser pattern from `personaStream.js`). Persist history in `chat.history`.

**Acceptance:** on a character's page you can ask "how was that last fight?" and get an
in-voice reply that references real state (their level, a skill they forged, the enemy they
just beat); the thread persists across refresh.

---

## Phase 4 — Voice: hear them speak

**4.1 Per-message play button** — every character chat bubble gets a ▶ button. On tap, run
the existing TTS narrator (`web/tts.js`) with the persona's `voiceId`/`voice` design fields
(the same pipeline the diary + persona preview already use). Show a loading→playing→replay
state on the button.

**4.2 Cache audio** — store generated WAV in the `chatAudio` IndexedDB store keyed by message
id (mirror `voices`/`portraits`); replay is instant and offline. Skip regeneration if cached.

**4.3 (Optional) auto-speak toggle** — a per-character setting to auto-play replies as they
finish streaming.

**Acceptance:** tapping ▶ on a reply speaks it in the character's voice; replays from cache
with no re-synth; works on mobile (respects the tap-to-unlock-audio gesture).

---

## Phase 5 — Emotes: condition-driven character/enemy reactions

Heroes **and** enemies pop a little emote bubble above their sprite when something happens
(took a hit, killed a foe, low HP, afflicted, leveled, idle). Uses the **Minifantasy UI
Overhaul "Character_Emotions"** pack already in the repo — no model needed.

**Assets (already present in `../auto-battler/public/assets/.../Character_Emotions/`):**
- `_Emotions.png` (152×104) — a sheet of **8×8px** emoticon faces: angry, happy, sad,
  surprised, dizzy/dazed, ❤ love, 💤 sleepy, ♪ music, sweat/fear, sick, etc.
- `_Bubble.png` (280×72) — speech-bubble frames with the tail pointing 8 directions, so the
  bubble can sit above a sprite. (`Bubble_Only` / `Bubble_Outline` variants too.)

**5.1 Curate the pack into tiny-army** — add the emote + bubble PNGs to `curate_assets.py`'s
keep-list so `./build.sh` copies them into `web/assets/`. They're tiny (KBs).

**5.2 Emote sheet decoder** (`../auto-battler/src/render/emotes.js`, bundled to `web/`)
Decode `_Emotions.png` as an 8×8 grid → a named map (`{ angry, happy, sad, surprised, dizzy,
love, sleepy, music, sweat, sick, ... }`), and the bubble frame by tail direction. Mirror the
existing `spriteSheet.js`/`sheet.js` decode pattern (rows×cols, nearest-neighbor scale).

**5.3 Emote render layer** (`../auto-battler/src/render/comboBattler.js` + sprite scene)
A small overlay attached to each actor sprite: `showEmote(actorId, key, { ms })` spawns the
bubble + 8×8 icon above the sprite (scaled to match `groundScale`), auto-expires, and is
culled with the actor. Pixel-snapped, no blur. One emote at a time per actor (latest wins).

**5.4 Condition → emote mapping** (engine events → render)
Drive it off events the resolver/roam loop already produces (`teamBattle.js` logs hits,
conditions, deaths; `comboBattler` knows `.alive`/HP). A small, tunable table, e.g.:

| Trigger (condition)            | Hero emote | Enemy emote |
|--------------------------------|-----------|-------------|
| Took a hit / `dmg`             | 😠 angry  | 😠 angry    |
| Afflicted (`bleeding`/`poison`)| 😣 sick   | 😣 sick     |
| Stunned / `interrupt` / dazed  | 😵 dizzy  | 😵 dizzy    |
| HP crosses low threshold       | 😨 sweat  | 😨 sweat    |
| Killed a foe                   | 😄 happy  | —           |
| On death                       | —         | 💀/😵 dizzy |
| Level-up (hero, Phase 1)       | ❤ love    | —           |
| Idle a while / out of combat   | 💤 sleepy | 💤 sleepy   |

Debounce so a flurry of hits doesn't strobe (min interval per actor). Keep the table in one
place so it's easy to retune. Rebuild via `./build.sh`.

**5.5 (small) Settings toggle** — an "emotes" on/off in the existing settings; default on.

**Acceptance:** in roam combat, taking damage, getting bled/stunned, dropping low, and killing
foes each pop the right 8×8 emote bubble over the correct sprite (hero and enemies); a level-up
shows the ❤ emote; the bubble scales correctly on mobile and is pixel-crisp.

---

## Cross-cutting

- **Mobile-first**: every new surface is single-column ≤768px, controls in the thumb zone,
  bottom-sheets over modals, lazy-loaded media. Matches the existing joystick/attack mobile work.
- **Graceful degradation**: any model call (forge/chat/voice) can fail → always a non-blocking
  fallback (templated skill, text-only reply, silent button). The app already degrades when
  sidecars are down; preserve that.
- **Build discipline**: engine/skill edits → `../auto-battler/src``./build.sh` → verify.
  Run local model sidecars with `./run_sidecars.sh` (Aya :7864, Klein :7865); BLS code model
  via its space/sidecar.
- **Persistence is client-side** by design (hackathon). If cross-device sync is ever needed,
  the one new thing required is a `/personas` GET/POST backend store — out of scope here.

## Dependency order
```
Phase 0 (state)  ──►  Phase 1 (leveling)  ──►  Phase 2 (skill forge + detail art)
                 └─►  Phase 3 (chat) ──► Phase 4 (voice)

Phase 5 (emotes) ── needs only engine/render access; richer once Phase 1 events exist.
                    Can be built in parallel; level-up emote depends on Phase 1.
```
Phase 1, 3, and 5 can proceed in parallel once Phase 0 lands (chat doesn't need leveling but
reads richer state once leveling/forge exist; emotes are mostly self-contained render work).

## Model assignments
| Capability        | Model                          | Path |
|-------------------|--------------------------------|------|
| Skill code (IR)   | BLS Mini-Code 30B              | `TINY_BLS_CODE_SPACE`, reasoning stripped |
| Skill flavor      | Tiny Aya / MiniCPM / Mellum    | `/text/generate/stream` |
| Skill icon + action shot | FLUX.2 Klein 4B          | `/portrait` (:7865) — icon (square) + character-performing-skill illustration |
| Emote graphics    | none (Minifantasy art pack)    | `Character_Emotions/_Emotions.png` (8×8) + `_Bubble.png` |
| Character chat     | Tiny Aya / MiniCPM / Mellum   | `/chat/stream``/text/generate/stream` |
| Voice             | Kokoro / Qwen3-TTS / VoxCPM    | `web/tts.js` |

## Open decisions (pick before/while building)
1. **XP source**: kills-only (simplest), or also reward skill use / survival? → start kills-only.
2. **Forge trigger**: auto-offer one skill per level, or a spendable "forge token"? → token, so
   forging is a deliberate, art-worthy moment.
3. **Equipped cap**: keep the engine's 3-skill bar, or expand? → keep 3 for balance + UI fit.
4. **Chat state breadth**: how much game state in the turn (just stats, or full recent event
   log)? → start with stats + last 5 events; expand if replies feel thin.