2026 10 13 look unlocks design - xopherdeep/do-it-for-the-xp GitHub Wiki

Look Unlocks (project 1 of 3) — design

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).

Why

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:

  1. 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.
  2. Content drop: many more hair styles, hats and outfits (voxel geometry), plus more colours.
  3. 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.

Decisions (owner, 2026-10-13)

  • 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.

The catalogue

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_STYLES and LOOK_COLORS has 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.

The first content batch

Class expressions (31 faces)

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 milestones (9 pieces)

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).

What a hero owns

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 fallback Not unlocked yet until they do.
  • newPieces(hero): ReadonlySet<string> — owned, non-starter, not grandfathered, not in lookSeen.
  • 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, or DEFAULT_LOOK for a new hero); returned lists 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 to lookUnlocks and persists; false for an unknown id or one already owned. Every project-3 source calls this.
  • markLookSeen(userId, pieceIds) — adds to lookSeen and persists.
  • Old profiles have neither field: both read as empty. No migration.

In the builder

The builder (XpHeroBuilder.vue and its parts) learns about ownership; its layout does not change.

  • Who: XpHeroBuilderModal and XpHeroBuilder take a hero?: LookHero | null prop (the profile; a brand-new profile passes its draft, which owns only the starter set). They compute owned and newPieces from it and emit seen(pieceIds); portal hosts call markLookSeen, 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) and aria-label ", locked"; a new one shows a small "New" badge until tapped (the tap emits seen). 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 lockedHint underneath (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?) and starterLineup(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.

Testing

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_REQUIREMENTS plus 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.
  • lockedHint copy; newPieces; keepOwned (returns per slot, fallback to saved look or DEFAULT_LOOK, colours on every colour key); randomLook/starterLineup with an owned set never pick a locked value.
  • Store: grantLook adds once, refuses unknown and owned ids, persists; markLookSeen persists.
  • 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.

Out of scope (projects 2 and 3)

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.

⚠️ **GitHub.com Fallback** ⚠️