feat: character creation wizard (#108) #134

Merged
marco merged 10 commits from feat/character-creation into main 2026-07-22 13:25:09 +02:00
Owner

Summary

Character creation (#108): a first-login, UO-style wizard to build the character that's mine
pick a hair style, beard style, hair/beard/skin colour and a name, with a live
paperdoll preview (the same UO paperdoll the in-game character sheet draws — same frame, same
front orientation), a Randomize shortcut, and a live name-availability check. From then on
that look is what nearby players see in the world.

The important call this PR makes (a mid-design pivot): appearance is game state, not identity.
Auth stays identity-only (the name); the client sends a validated SetAppearance intent to the
GameServer, which owns it — validates every choice against a server catalog, accepts it once
(a barber is a later feature), persists it in the save blob (PlayerMobile v3), and reconciles it
to observers via the existing appearance pass. This keeps the strict JWT-only Auth↔GameServer
contract clean instead of leaking "hair/beard" into the auth service.

Rendering uses a runtime per-layer tint on hue-neutral grey-ramp UO art (extracted from the
classic client): in-world, hair/beard are anim overlay layers over the body, each multiplied by its
colour (skin tints the body); the paperdoll/preview do the same with the hair/beard gumps via a
shared PaperdollFigure — so the preview and the in-game paperdoll are identical by construction,
and colours are a client-side table (AppearancePalette), keeping the wire a stable semantic
key (art-agnostic). Discovered UO art ids are recorded in docs/uo-asset-map.md so we don't
re-hunt them.

Name handling is hardened server-side (the name rides every AoI broadcast): max length,
letters+spaces whitelist, NFKC + zero-width/combining strip before the world-unique check, and a
reserved-word blocklist. POST /me/character returns a typed reason (too-short / taken /
invalid-chars / reserved / …) so the wizard shows a specific inline message, and a new
GET /me/character/available powers the live check.

While building the two new screens we extracted a small reusable IsoMmo.Client.UI widget design
system
(Widget base with Visible/Enabled, Button, self-focusing TextInputWidget, Label, Panel,
OptionStepper, SwatchPicker, WidgetGroup). Screens compose widgets and stay thin coordinators
(Screen HARD GATE); login was refactored onto it, and the dedicated CharacterCreationScreen is
not bolted onto login. Documented in docs/ui-widgets.md.

Follow-ups filed: #133 (TLS end-to-end before public exposure). ProtocolVersion bumped 8 → 9.
Known limitation: the male beard paperdoll gump set is sparse (~2), so a few beard styles reuse a
gump on the doll while staying distinct in-world — sourcing more is a follow-up.

Screenshots / recording

Piloted from a fresh database (wiped auth db + world.sav) via the debug harness
(docs/debug-harness.md): wizard <user> → drive with wiz …screenshot, then wiz confirm
open doll, and logout to build the next character.

The wizard (note "Enter world" stays disabled until the name is valid):

wizard

Consistency across three combinations — the wizard preview matches the in-game paperdoll,
same figure, same orientation
(long-hair+goatee / pageboy / mohawk+mustache, different colours &
skin tones):

compare

A created character's in-game paperdoll + the same character in the world:

doll

Commands (abridged): wizard gimli; wiz hair 1; wiz beard 3; wiz haircolor 3; wiz skin 2;
wiz name Gimli; wiz confirm; open doll; logout; wizard legolas

How it was tested

  • Unit tests per layer: Shared (CharacterAppearance/AppearanceCatalog validation, wire
    round-trip incl. SetAppearance); GameServer (ChooseAppearance valid / set-once-rejected /
    out-of-catalog-rejected / save-restore round-trip); Auth (name too-long / digits / reserved /
    zero-width-collision + availability free/taken); Client.Core (AppearancePalette distinct +
    fallback, AppearanceSelection cycle/wrap/randomize).
  • just lint (CSharpier + analyzers, zero warnings) and just test (377 tests) green; whole
    solution builds incl. client + tools.
  • Manually piloted the wizard end-to-end via the harness from a fresh DB across three appearance
    combinations (screenshots above), verifying preview == in-game paperdoll each time.

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS)
  • Tests added/updated for this change
  • Linked the related issue (#108) and its Definition of Done is met

🤖 Generated with Claude Code

## Summary Character creation (#108): a first-login, UO-style wizard to build the character that's *mine* — pick a **hair style**, **beard style**, **hair/beard/skin colour** and a **name**, with a live **paperdoll preview** (the *same* UO paperdoll the in-game character sheet draws — same frame, same front orientation), a **Randomize** shortcut, and a **live name-availability** check. From then on that look is what nearby players see in the world. The important call this PR makes (a mid-design pivot): **appearance is game state, not identity.** Auth stays identity-only (the name); the client sends a validated `SetAppearance` intent to the **GameServer**, which owns it — validates every choice against a server catalog, accepts it *once* (a barber is a later feature), persists it in the save blob (`PlayerMobile` v3), and reconciles it to observers via the existing appearance pass. This keeps the strict JWT-only Auth↔GameServer contract clean instead of leaking "hair/beard" into the auth service. Rendering uses a **runtime per-layer tint** on hue-neutral grey-ramp UO art (extracted from the classic client): in-world, hair/beard are anim overlay layers over the body, each multiplied by its colour (skin tints the body); the paperdoll/preview do the same with the hair/beard **gumps** via a shared `PaperdollFigure` — so the preview and the in-game paperdoll are identical by construction, and colours are a client-side table (`AppearancePalette`), keeping the wire a stable semantic **key** (art-agnostic). Discovered UO art ids are recorded in `docs/uo-asset-map.md` so we don't re-hunt them. Name handling is hardened server-side (the name rides every AoI broadcast): max length, letters+spaces whitelist, NFKC + zero-width/combining strip before the world-unique check, and a reserved-word blocklist. `POST /me/character` returns a **typed reason** (too-short / taken / invalid-chars / reserved / …) so the wizard shows a specific inline message, and a new `GET /me/character/available` powers the live check. While building the two new screens we extracted a small reusable **`IsoMmo.Client.UI` widget design system** (Widget base with Visible/Enabled, Button, self-focusing TextInputWidget, Label, Panel, OptionStepper, SwatchPicker, WidgetGroup). Screens compose widgets and stay thin coordinators (Screen HARD GATE); login was refactored onto it, and the dedicated `CharacterCreationScreen` is **not** bolted onto login. Documented in `docs/ui-widgets.md`. Follow-ups filed: **#133** (TLS end-to-end before public exposure). `ProtocolVersion` bumped 8 → 9. Known limitation: the male beard *paperdoll* gump set is sparse (~2), so a few beard styles reuse a gump on the doll while staying distinct in-world — sourcing more is a follow-up. ## Screenshots / recording Piloted from a **fresh database** (wiped auth db + `world.sav`) via the debug harness (`docs/debug-harness.md`): `wizard <user>` → drive with `wiz …` → `screenshot`, then `wiz confirm` → `open doll`, and `logout` to build the next character. **The wizard** (note "Enter world" stays disabled until the name is valid): ![wizard](https://git.homelab.devncode.it/attachments/24740fd3-c141-44f4-921b-895f7e2b4877) **Consistency across three combinations — the wizard preview matches the in-game paperdoll, same figure, same orientation** (long-hair+goatee / pageboy / mohawk+mustache, different colours & skin tones): ![compare](https://git.homelab.devncode.it/attachments/a239aef1-3253-4fe7-a9f8-8ed74a5eff19) **A created character's in-game paperdoll + the same character in the world:** ![doll](https://git.homelab.devncode.it/attachments/e4c4375a-fa9a-490d-826d-e4fc058dbf34) Commands (abridged): `wizard gimli`; `wiz hair 1`; `wiz beard 3`; `wiz haircolor 3`; `wiz skin 2`; `wiz name Gimli`; `wiz confirm`; `open doll`; `logout`; `wizard legolas` … ## How it was tested - **Unit tests per layer**: Shared (`CharacterAppearance`/`AppearanceCatalog` validation, wire round-trip incl. `SetAppearance`); GameServer (`ChooseAppearance` valid / set-once-rejected / out-of-catalog-rejected / save-restore round-trip); Auth (name too-long / digits / reserved / zero-width-collision + availability free/taken); Client.Core (`AppearancePalette` distinct + fallback, `AppearanceSelection` cycle/wrap/randomize). - `just lint` (CSharpier + analyzers, zero warnings) and `just test` (377 tests) green; whole solution builds incl. client + tools. - Manually piloted the wizard end-to-end via the harness from a fresh DB across three appearance combinations (screenshots above), verifying preview == in-game paperdoll each time. ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS) - [x] Tests added/updated for this change - [x] Linked the related issue (#108) and its Definition of Done is met 🤖 Generated with [Claude Code](https://claude.com/claude-code)
CharacterAppearance (semantic keys for hair/beard style + colours + skin
tone) + AppearanceCatalog (server-owned valid sets + default). PlayerAppearance
carries the chosen look; PlayerMobile exposes it via ToState. A new SetAppearance
client intent lets a brand-new character pick its look — appearance is game
state owned by the GameServer, never identity, so it does not touch Auth. Bump
ProtocolVersion 8 -> 9.
PlayerMobile.ChooseAppearance is a one-time, self-validating transition
(rejects out-of-catalog and repeat attempts), persisted in the blob (v3).
World delegates; GameSessionHandler routes the SetAppearance intent on the
sim thread; the accepted change reconciles to observers via the tick's
appearance pass. Appearance is game state owned here — never Auth.
Server-side name hardening (CharacterNames): max length, letters+spaces
whitelist, NFKC + zero-width/combining strip before the world-unique check,
reserved-word blocklist. /me/character returns a typed CharacterNameError
(too-short/too-long/invalid-chars/reserved/taken/already-has) so the wizard
shows a specific inline message; a new /me/character/available powers the
live name check. Auth stays identity-only — no appearance.
AssetExtractor packs the male hair (7 styles) and beard (5 styles) anim
bodies as hair/<style>/<action>_<dir> and beard/<style>/<action>_<dir>,
same layout as worn equipment. The art is a hue-neutral grey ramp, so the
client tints it per-layer from the chosen colour key (no baked colour
variants). 'none' has no art (bald / clean-shaven).
Player draws the chosen hair and beard as grey-ramp overlay layers in
lockstep with the body, each multiplied by its colour, and tints the body
by skin tone (the body art is a grey ramp too). Colours come from an
engine-independent AppearancePalette in Client.Core (key -> RGB); the ghost
tint still overrides all layers. AssetPackLoader builds hair/beard sets by
style; missing art falls back to bald / clean-shaven.
Extract a small immediate-mode widget set into namespace IsoMmo.Client.UI:
Widget (now with Visible/Enabled), Button, TextInputWidget (self-focus on
click + Changed event), and new Label, Panel, OptionStepper, SwatchPicker,
WidgetGroup. Screens compose widgets via a WidgetGroup and stay thin
coordinators. The dedicated CharacterCreationScreen (not bolted onto
LoginScreen) picks hair/beard style + colours + skin + name with a live
front-sprite preview (same rendering as in-world), Randomize, and a live
name-availability check; on confirm the name goes to Auth and the look is
stashed for the GameServer (SetAppearance) on connect. Login refactored onto
the widget set.
feat(client): drive the creation wizard from the debug harness + docs (#108)
All checks were successful
ci / Lint & Test (pull_request) Successful in 1m41s
bdf2869710
Harness gains 'wizard <user>' (open the wizard) and 'wiz <sub> [arg]' (cycle
styles, pick swatches, randomize, set name, confirm) so the wizard can be
driven and screenshotted; 'login' stays the default-appearance bypass for
non-creation tests. Document the IsoMmo.Client.UI widget system in
docs/ui-widgets.md (how it works + how to build a screen, no widget list),
note the harness commands, and record the resolved design decisions.
feat(client): paperdoll-style creation preview matching the in-game paperdoll (#108)
Some checks failed
ci / Lint & Test (pull_request) Has been cancelled
dce7514b7d
The wizard preview is now the SAME UO paperdoll the in-game character sheet
draws — a shared PaperdollFigure (male body tinted by skin + hair/beard gumps
tinted by their colours + worn gear) rendered into the 0x07D0 frame, so
'what you see in the preview is what you see in the paperdoll', same front
orientation. The in-game paperdoll gains hair/beard (same gump style); the
figure sits a touch right of centre.

Extract the male hair (7 styles) + beard paperdoll gumps from UO
(gumpart.mul), tinted like the in-world grey-ramp art; add a 'gumpdump' dev
command that found them. Record discovered UO art ids in docs/uo-asset-map.md
so we don't re-hunt them. Harness 'logout' returns to login so a script can
create several characters. Beard paperdoll gumps are sparse (~2) so some
styles reuse one on the doll while staying distinct in-world (follow-up).
docs: correct #108 preview note to paperdoll-style
All checks were successful
ci / Lint & Test (pull_request) Successful in 2m25s
dbeef213a1
fix(assets): compute hair/beard paperdoll gumps from anim body (+50000) (#108)
All checks were successful
ci / Lint & Test (pull_request) Successful in 5m23s
ff8217c1c4
The hand-picked hair gumps were elf hair (bodies 0x0379-0x037F) and didn't
match the in-world sprite. ClassicUO computes the male paperdoll gump as
animBody + 50000 (MALE_GUMP_OFFSET), so derive the gump from the SAME anim
body the in-world overlay uses — paperdoll and walking sprite are now the
same hairstyle by construction. Extractor computes gump/<kind>_<style>_male;
gumps.json no longer hand-lists them. Asset map records the formula + sources
(ClassicUO/ServUO).
marco merged commit 812653e425 into main 2026-07-22 13:25:09 +02:00
marco deleted branch feat/character-creation 2026-07-22 13:25:09 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!134
No description provided.