2026 10 13 look unlocks design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-13. Status: design approved in chat by the owner, section by section; this spec awaits review.
Builds on: 2026-10-13-hero-builder-design.md (the Mii-style Hero Builder, shipped).
The owner wants many more look options, and wants heroes to earn them as they play: expressions learned through classes, outfits bought in shops, some colours bought, rare pieces from temples, beasts, quests, seasons and the travelling merchant's rotating stock. Looks become something kids work toward.
The work is split in three:
- Look Unlocks (this spec): the catalogue of look pieces, what a hero owns, locks and try-on in the builder, one grant call for every later source, and a first content batch (class expressions and hero-level milestones) so the locks have something real in them on day one.
- Content drop: many more hair styles, hats and outfits (voxel geometry), plus more colours.
- Sources: region shops, the Home Town barber, the travelling merchant's rare rotating stock, temple treasure, beast drops, quest rewards, seasonal pieces, GM gifts and repricing in the Compendium, unlock announcements.
- Small free starter set. Every option that exists today is free (10 hair, 6 faces, 8 outfits, 9 hats, 16 colours) and so are all 18 skin tones. Everything new is earned.
- Skin is never locked. It is who you are, not gear.
- Shops are built in; the GM can tweak (project 3): each piece ships on a shelf with a GP price; the GM can reprice, hide or gift in the Compendium.
- Classes teach a few expressions each, kept forever once learned (Final Fantasy V style).
- All sources above; the merchant's rare rotating stock is a highlight (project 3).
- Try it on, can't keep. A locked piece can be worn on the stage with a padlock banner saying how to get it; Done keeps owned pieces and puts locked ones back on the shelf.
- Catalogue + owned list, earned ones computed (option 1): class and level unlocks are derived from progress every time, never stored, so existing heroes need no migration.
- Grandfathering: whatever a hero's saved look already holds counts as owned. No hero ever loses a piece.
- Colours unlock everywhere: one colour piece makes that colour usable on hair, eyes, outfit and hat.
src/lib/hero-look/catalog.ts (pure, no Vue).
type PieceSlot = "hair" | "face" | "outfit" | "hat" | "color";
type LookSource =
| { kind: "starter" }
| { kind: "class"; jobClass: string; level: number }
| { kind: "level"; level: number }
| { kind: "gift" }
// declared now, granted by project 3
| { kind: "shop"; shop: string; price: number }
| { kind: "barber"; price: number }
| { kind: "merchant"; price: number }
| { kind: "temple"; temple: string }
| { kind: "beast"; beast: string }
| { kind: "quest"; quest: string }
| { kind: "season"; season: string };
interface LookPiece { id: string; slot: PieceSlot; value: string; name: string; source: LookSource }- Piece id:
${slot}:${value}(colour values are lower-case hex):face:battle-cry,hat:circlet,color:#8fd9b6. -
LOOK_CATALOG: LookPiece[]holds every piece.pieceById(id),piecesForSlot(slot)(starter pieces first, then earned ones in catalogue order). - Every value in
HAIR_STYLES,FACE_STYLES,OUTFIT_STYLES,HAT_STYLESandLOOK_COLORShas exactly one piece. - Display names come from the catalogue (
"Battle Cry","Feathered Cap"); the builder's tile labels use them. - No rarity yet: project 3 adds it with the merchant.
Base classes teach three (class level 1, 3, 5), tier-2 classes two (1, 5), tier-3 classes one signature (1).
| Class | Lv 1 | Lv 3 | Lv 5 |
|---|---|---|---|
| Warrior | Battle Cry battle-cry
|
Steely Glare steely-glare
|
Victory Grin victory-grin
|
| Mage | Wide-Eyed Wonder wonder
|
Spell Focus spell-focus
|
Knowing Smirk knowing-smirk
|
| Thief | Sly Side-Eye side-eye
|
Cheeky Tongue cheeky-tongue
|
Shifty Grin shifty-grin
|
| Monk | Serene serene
|
Deep Breath deep-breath
|
Inner Fire inner-fire
|
| Knight | Noble noble
|
Valiant valiant
|
|
| Berserker | Fury fury
|
Wild Laugh wild-laugh
|
|
| White Mage | Gentle gentle
|
Blessing blessing
|
|
| Black Mage | Shadow Eyes shadow-eyes
|
Spellbound spellbound
|
|
| Assassin | Cold Stare cold-stare
|
Unseen unseen
|
|
| Ranger | Keen Squint keen-squint
|
Whistle whistle
|
|
| Martial Artist | Kiai! kiai
|
Iron Focus iron-focus
|
|
| Mystic | Third Eye third-eye
|
Dreamy dreamy
|
|
| Paladin | Radiant radiant
|
||
| Ninja | Silent silent
|
||
| Sage | Wise wise
|
Each face is a set of flat boxes on the face plane like today's six (voxel/head.ts): eyes, brows, mouth, extras
(a sparkle for Wonder, a dot for Third Eye, a glow for Radiant, a mask band for Unseen and Silent), drawn
from the hero's eye colour, skin-derived brow and blush, and the shared PALETTE. Faces move to voxel/faces.ts
so head.ts stays small.
| Hero level | Piece | Id |
|---|---|---|
| 3 | Mint | color:#8fd9b6 |
| 5 | Feathered Cap | hat:feathered-cap |
| 8 | Sky | color:#7ec4f0 |
| 10 | Circlet | hat:circlet |
| 12 | Plum | color:#7a3b6e |
| 15 | Traveller's Coat | outfit:travellers-coat |
| 20 | Horned Helm | hat:horned-helm |
| 25 | Silver | color:#c8ccd4 |
| 30 | Halo | hat:halo |
The four hats go in voxel/hats.ts, the coat in voxel/outfits.ts (long coat over the tunic body, uses
outfitColor and outfitAccent). The halo floats above the head and does not hide hair (HAT_COVERS_TOP false).
src/lib/hero-look/unlocks.ts (pure).
interface LookHero {
look?: unknown; // the saved look (grandfathering)
jobClass?: string;
stats?: { level?: number; classes?: Record<string, { level?: number }> };
lookUnlocks?: string[]; // pieces bought, found or gifted
lookSeen?: string[]; // earned pieces already seen in the builder (New badges)
}
ownedPieces(hero: LookHero | null | undefined): ReadonlySet<string>Owned = starter pieces
∪ class pieces whose class level is reached (classLevelsOf(stats.classes); the hero's current jobClass counts as
at least level 1)
∪ level pieces whose level stats.level has reached
∪ lookUnlocks (ids not in the catalogue are ignored)
∪ the pieces of the saved look (getLook(hero) values for hair, face, outfit, hat and the four colour keys).
Helpers:
-
isOwned(owned, slot, value); skin is always owned. -
lockedHint(piece): string— class:Learned as a Warrior (class level 3); level:Reach hero level 10; gift:A gift from the Game Master; the project-3 kinds get their copy then, with a neutral fallbackNot unlocked yetuntil they do. -
newPieces(hero): ReadonlySet<string>— owned, non-starter, not grandfathered, not inlookSeen. -
keepOwned(draft, fallback, owned): { look: HeroLook; returned: LookPiece[] }— every locked slot value in the draft goes back to the fallback look's value for that slot (the saved look, orDEFAULT_LOOKfor a new hero);returnedlists the pieces that went back, in slot order.
Store (src/lib/store/stores/user/profileFields.ts):
-
grantLook(userId, pieceId): boolean— adds a catalogue piece the hero does not own yet tolookUnlocksand persists; false for an unknown id or one already owned. Every project-3 source calls this. -
markLookSeen(userId, pieceIds)— adds tolookSeenand persists. - Old profiles have neither field: both read as empty. No migration.
The builder (XpHeroBuilder.vue and its parts) learns about ownership; its layout does not change.
-
Who:
XpHeroBuilderModalandXpHeroBuildertake ahero?: LookHero | nullprop (the profile; a brand-new profile passes its draft, which owns only the starter set). They computeownedandnewPiecesfrom it and emitseen(pieceIds); portal hosts callmarkLookSeen, the Gamer Card ignores it for a profile not yet saved. -
Tiles and swatches: a locked one is dimmed with a padlock (
fad fa-lock) andaria-label", locked"; a new one shows a small "New" badge until tapped (the tap emitsseen). Locked tiles stay tappable. -
Try-on: when the draft holds any locked piece, the stage shows a banner at its top: padlock, "Trying on the
" and the
lockedHintunderneath (the most recent locked pick when there are several). Undo and Random work as before. -
Random and the starter lineup draw only from owned values (
randomLook(rng, owned?)andstarterLineup(count, rng, owned?)take the owned set; with none they behave as today). -
Done:
keepOwned(draft, savedLook ?? DEFAULT_LOOK, owned). Nothing returned → the Done sequence as today. Something returned → an RPG alert "Not yours yet" with the message naming each returned piece and its hint ("The Wizard Hat went back on the shelf. Sold by the travelling merchant, 120 GP.") and "Everything else you picked is saved.", buttons [Keep building] (role cancel) and [Save] — Save runs the Done sequence with the kept look. -
Names: tile labels and
aria-labels come from the catalogue names.
Jest (via chemx test):
- Catalogue integrity: ids unique and well-formed; every style and colour value has exactly one piece; the 31 class
faces and 9 milestones match the tables (class, level, id, name); every class in
JOB_UNLOCK_REQUIREMENTSplus the four base classes teaches the right count. - Ownership: starter only for an empty hero; class pieces at and above their level, current class counts as Lv 1; level milestones; stored unlocks; unknown stored ids ignored; grandfathered saved-look pieces; skin always owned.
-
lockedHintcopy;newPieces;keepOwned(returns per slot, fallback to saved look or DEFAULT_LOOK, colours on every colour key);randomLook/starterLineupwith an owned set never pick a locked value. - Store:
grantLookadds once, refuses unknown and owned ids, persists;markLookSeenpersists. - Voxel: every new face, hat and outfit builds a hero with the named groups; the halo leaves hair visible.
- Builder: locked tiles dimmed and labelled; New badge clears on tap and emits
seen; try-on banner text; Done with a locked piece shows the alert and Save keeps owned pieces; Random only owned.
Cypress: mylook.spec.js gains one case: a hero with Warrior class level 3 sees Battle Cry and Steely Glare unlocked
and Victory Grin locked; tries on Victory Grin, Done, Save → the saved face is the previous one.
Gate: chemx verify before each commit.
New hair, more hats and outfits beyond the milestone pieces, more colours beyond the four milestone colours, rarity, shops / barber / merchant / temples / beasts / quests / seasons as live sources, GM gift and reprice UI, unlock announcements outside the builder (toasts on level-up), selling or trading pieces.