diff --git a/VERSION b/VERSION index 762033b..b714bda 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.49-beta +0.4.50-beta diff --git a/public/js/avatar.js b/public/js/avatar.js index ef0824c..a7f0d6d 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -6,19 +6,27 @@ los ernaast te zweven. */ "use strict"; const AVATAR_SPECIES = ["dino", "monster", "animal"]; +const AVATAR_VARIANTS = Object.freeze({ + dino: ["rex", "triceratops", "stegosaurus", "raptor"], + monster: ["horned", "cyclops", "slime", "alien"], + animal: ["cat", "dog", "rabbit", "bear"], +}); const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"]; const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat"]; const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"]; const AVATAR_DEFAULT = Object.freeze({ - species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", + species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", }); const AVATAR_SVG_NS = "http://www.w3.org/2000/svg"; function avatarSanitize(config){ const c = config || {}; + const species = AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species; + const variants = AVATAR_VARIANTS[species]; return { - species: AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species, + species, + speciesVariant: variants.includes(c.speciesVariant) ? c.speciesVariant : variants[0], bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor, hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle, hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor, @@ -43,48 +51,86 @@ function avatarShade(hex, amt){ derde van de figuurbreedte; romp en ledematen blijven kort en sluiten onder de wangen aan. Alle soorten delen dezelfde accessoire-ankers rond hals y=61, borst y=72 en middel y=87. */ -function avatarBodyMarkup(species, bodyColor){ +function avatarBodyMarkup(species, bodyColor, speciesVariant){ + const variants = AVATAR_VARIANTS[species] || AVATAR_VARIANTS[AVATAR_DEFAULT.species]; + const variant = variants.includes(speciesVariant) ? speciesVariant : variants[0]; const outline = avatarShade(bodyColor, -0.48); const dark = avatarShade(bodyColor, -0.24); const light = avatarShade(bodyColor, 0.4); const highlight = avatarShade(bodyColor, 0.72); - let m = ""; + let m = ``; - /* Soortdetails achter kop en lijf. */ + /* Silhouetdetails achter kop en lijf: iedere variant is direct herkenbaar. */ if(species === "dino"){ - m += ``; - m += ``; - } - if(species === "animal"){ - m += ``; - m += ``; - m += ``; - m += ``; - } - if(species === "monster"){ - m += ``; - m += ``; + if(variant === "rex"){ + m += ``; + }else if(variant === "triceratops"){ + m += ``; + m += ``; + }else if(variant === "stegosaurus"){ + m += ``; + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + } + }else if(species === "monster"){ + if(variant === "horned"){ + m += ``; + m += ``; + }else if(variant === "cyclops"){ + m += ``; + }else if(variant === "slime"){ + m += ``; + }else{ + m += ``; + m += ``; + } + }else{ + if(variant === "cat"){ + m += ``; + m += ``; + m += ``; + }else if(variant === "dog"){ + m += ``; + m += ``; + m += ``; + }else if(variant === "rabbit"){ + m += ``; + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + m += ``; + } } - /* Korte ledematen verschillen per wezen en verdwijnen onder de romp. */ + /* Korte ledematen blijven op gedeelde ankers voor alle accessoires. */ if(species === "dino"){ m += ``; m += ``; - m += ``; - m += ``; - m += ``; + if(variant === "triceratops"){ + m += ``; + m += ``; + }else if(variant === "raptor"){ + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + } }else if(species === "monster"){ m += ``; m += ``; m += ``; m += ``; - m += ``; }else{ m += ``; m += ``; m += ``; m += ``; - m += ``; } m += ``; @@ -93,56 +139,108 @@ function avatarBodyMarkup(species, bodyColor){ : species === "monster" ? "M 35 62 C 42 57 58 57 65 62 C 71 70 70 85 64 92 C 56 97 44 97 36 92 C 30 85 29 70 35 62 Z" : "M 38 61 C 44 58 56 58 62 61 C 67 69 66 84 61 93 C 54 97 46 97 39 93 C 34 84 33 69 38 61 Z"; - m += ``; + m += ``; + if(species === "dino"){ - m += ``; - m += ``; + if(variant === "raptor"){ + m += ``; + }else{ + m += ``; + if(variant === "stegosaurus") m += ``; + } }else if(species === "monster"){ m += ``; - m += ``; + if(variant === "horned") m += ``; + if(variant === "cyclops") m += ``; + if(variant === "slime") m += ``; + if(variant === "alien") m += ``; }else{ - m += ``; + if(variant === "cat") m += ``; + if(variant === "dog") m += ``; + if(variant === "rabbit") m += ``; + if(variant === "bear") m += ``; } - /* De forse kop overlapt de hals en de bovenkant van het kleine lijf. */ - const headPath = species === "monster" - ? "M 50 -3 C 29 -5 16 8 16 30 C 15 51 29 63 48 64 C 69 66 85 53 84 30 C 84 8 71 -2 50 -3 Z" - : species === "dino" - ? "M 47 -3 C 28 -3 15 10 16 31 C 16 49 28 60 45 63 C 64 67 83 55 85 35 C 87 15 73 -1 55 -2 Z" - : "M 50 -3 C 30 -3 17 10 17 31 C 17 52 30 64 50 64 C 70 64 83 52 83 31 C 83 10 70 -3 50 -3 Z"; - m += ``; + const headPaths = { + dino: { + rex: "M 47 -3 C 28 -3 15 10 16 31 C 16 49 28 60 45 63 C 64 67 83 55 85 35 C 87 15 73 -1 55 -2 Z", + triceratops: "M 50 -1 C 29 -2 16 11 17 31 C 17 51 30 63 50 64 C 70 64 83 51 83 31 C 84 11 71 -1 50 -1 Z", + stegosaurus: "M 48 -2 C 29 -2 16 10 16 31 C 16 50 29 62 48 64 C 68 66 84 53 84 32 C 84 11 69 -2 48 -2 Z", + raptor: "M 43 -2 C 26 1 16 14 18 33 C 19 50 30 60 46 62 C 64 65 80 55 87 40 C 91 30 83 24 76 21 C 74 7 61 -4 43 -2 Z", + }, + monster: { + horned: "M 50 -3 C 29 -5 16 8 16 30 C 15 51 29 63 48 64 C 69 66 85 53 84 30 C 84 8 71 -2 50 -3 Z", + cyclops: "M 50 -2 C 29 -3 16 10 16 31 C 16 53 30 65 50 65 C 70 65 84 53 84 31 C 84 10 71 -2 50 -2 Z", + slime: "M 49 0 C 30 -4 17 9 18 29 C 12 39 18 48 23 51 C 24 62 34 65 43 61 C 49 68 59 67 63 61 C 75 64 85 55 81 45 C 89 36 84 25 80 21 C 78 6 66 -2 49 0 Z", + alien: "M 50 -2 C 32 -2 20 9 18 28 C 16 48 28 62 50 65 C 72 62 84 48 82 28 C 80 9 68 -2 50 -2 Z", + }, + animal: { + cat: "M 50 -3 C 30 -3 17 10 17 31 C 17 52 30 64 50 64 C 70 64 83 52 83 31 C 83 10 70 -3 50 -3 Z", + dog: "M 50 0 C 29 -1 16 12 17 33 C 18 53 31 64 50 64 C 69 64 82 53 83 33 C 84 12 71 -1 50 0 Z", + rabbit: "M 50 -1 C 32 -1 20 11 19 31 C 18 51 30 64 50 66 C 70 64 82 51 81 31 C 80 11 68 -1 50 -1 Z", + bear: "M 50 -3 C 28 -3 15 10 16 32 C 17 54 30 65 50 65 C 70 65 83 54 84 32 C 85 10 72 -3 50 -3 Z", + }, + }; + m += ``; m += ``; if(species === "dino"){ - m += ``; - m += ``; - }else if(species === "animal"){ - m += ``; - m += ``; - } - - if(species === "monster"){ - m += ``; - m += ``; - m += ``; - }else{ - m += ``; - m += ``; - m += ``; - } - m += ``; - - if(species === "dino"){ + if(variant === "triceratops"){ + m += ``; + m += ``; + m += ``; + m += ``; + m += ``; + }else if(variant === "raptor"){ + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + if(variant === "stegosaurus") m += ``; + } + m += ``; + m += ``; m += ``; - }else if(species === "animal"){ - m += ``; + if(variant === "raptor") m += ``; + }else if(species === "monster"){ + if(variant === "cyclops"){ + m += ``; + m += ``; + }else if(variant === "alien"){ + for(const x of [34,50,66]) m += ``; + m += ``; + }else{ + const leftSize = variant === "slime" ? 8.5 : 10; + const rightSize = variant === "slime" ? 8.5 : 8.2; + m += ``; + m += ``; + if(variant === "slime"){ + m += ``; + }else{ + m += ``; + } + } }else{ - m += ``; - m += ``; + m += ``; + m += ``; + if(variant === "cat"){ + m += ``; + m += ``; + }else if(variant === "dog"){ + m += ``; + m += ``; + }else if(variant === "rabbit"){ + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + } } + m += ``; return m; } - const AVATAR_HAIR_MARKUP = { none: { back: () => "", front: () => "" }, tuft: { @@ -285,6 +383,30 @@ const AVATAR_HEAD_ACC_MARKUP = { `, }; +/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */ +function avatarHeadAccessoryMarkup(c){ + if(c.species === "monster" && c.speciesVariant === "cyclops"){ + if(c.accessoryHead === "glasses") return ` + + `; + if(c.accessoryHead === "sunglasses") return ` + + `; + if(c.accessoryHead === "eyepatch") return ` + + `; + } + if(c.species === "monster" && c.speciesVariant === "alien"){ + if(c.accessoryHead === "glasses") return ` + + + `; + if(c.accessoryHead === "sunglasses") return ` + + `; + } + return AVATAR_HEAD_ACC_MARKUP[c.accessoryHead](); +} const AVATAR_BODY_ACC_MARKUP = { none: { back: () => "", front: () => "" }, scarf: { @@ -368,16 +490,16 @@ function renderAvatarFace(config, size){ /* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */ svg.setAttribute("viewBox", "-8 -21 116 132"); svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); - svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species); + svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species + " av-variant-" + c.speciesVariant); svg.setAttribute("role", "img"); svg.setAttribute("aria-label", T("avatarAlt")); const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody]; const hair = AVATAR_HAIR_MARKUP[c.hairStyle]; let markup = bodyAccessory.back(); markup += avatarHairLayer(hair.back(c.hairColor), c.species, "back"); - markup += avatarBodyMarkup(c.species, c.bodyColor); + markup += avatarBodyMarkup(c.species, c.bodyColor, c.speciesVariant); if(!avatarHairSuppressed(c)) markup += avatarHairLayer(hair.front(c.hairColor), c.species, "front"); - markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead](); + markup += avatarHeadAccessoryMarkup(c); markup += bodyAccessory.front(); svg.innerHTML = markup; return svg; @@ -404,11 +526,19 @@ function buildAvatarEditor(container, initialConfig, onChange){ const b = document.createElement("button"); b.type = "button"; b.dataset.species = id; - b.addEventListener("click", ()=>{ config.species = id; update(); }); + b.addEventListener("click", ()=>{ config.species = id; config.speciesVariant = AVATAR_VARIANTS[id][0]; update(); }); speciesRow.appendChild(b); }); controls.appendChild(speciesRow); + const variantRow = document.createElement("div"); variantRow.className = "avatar-row"; + const variantField = document.createElement("label"); + const variantSpan = document.createElement("span"); + const variantSel = document.createElement("select"); + variantSel.addEventListener("change", ()=>{ config.speciesVariant = variantSel.value; update(); }); + variantField.append(variantSpan, variantSel); + variantRow.appendChild(variantField); + controls.appendChild(variantRow); const row1 = document.createElement("div"); row1.className = "avatar-row"; const bodyColorField = document.createElement("label"); const bodyColorSpan = document.createElement("span"); @@ -454,6 +584,13 @@ function buildAvatarEditor(container, initialConfig, onChange){ b.textContent = T("avatarSpecies_" + b.dataset.species); b.classList.toggle("active", b.dataset.species === config.species); }); + const variantIds = AVATAR_VARIANTS[config.species]; + if([...variantSel.options].map(o=>o.value).join(",") !== variantIds.join(",")){ + variantSel.innerHTML = ""; + variantIds.forEach(id=>variantSel.appendChild(new Option("", id))); + } + variantSpan.textContent = T("avatarVariant"); + [...variantSel.options].forEach(o=>o.textContent = T("avatarVariant_" + o.value)); bodyColorSpan.textContent = T("avatarBodyColor"); hairColorSpan.textContent = T("avatarHairColor"); hairSpan.textContent = T("avatarHairStyle"); @@ -464,12 +601,15 @@ function buildAvatarEditor(container, initialConfig, onChange){ [...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value)); } function update(){ + const variants = AVATAR_VARIANTS[config.species]; + if(!variants.includes(config.speciesVariant)) config.speciesVariant = variants[0]; + syncLabels(); bodyColorInput.value = config.bodyColor; hairColorInput.value = config.hairColor; + variantSel.value = config.speciesVariant; hairSel.value = config.hairStyle; headAccSel.value = config.accessoryHead; bodyAccSel.value = config.accessoryBody; - syncLabels(); preview.innerHTML = ""; preview.appendChild(renderAvatarFace(config, "lg")); if(onChange) onChange({ ...config }); @@ -518,6 +658,7 @@ async function saveAvatar(config){ visible: ()=>!!currentUser, activate(){ showSettingsDom("avatar"); build(); }, keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"), + ...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)), "personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"], }); })(); diff --git a/public/js/core.js b/public/js/core.js index e6cdcb5..3a35fc0 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.49-beta"; +const VERSION = "0.4.50-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -162,9 +162,12 @@ const I18N = { "brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast", wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.", complimentsDraw:"Trek een compliment", - wg_avatar:"Mijn personage", wg_avatar_d:"Stel je eigen personage samen: soort, kleuren, haar en accessoires.", + wg_avatar:"Mijn personage", wg_avatar_d:"Kies een dinosaurus, monster of dier en daarna de variant, kleuren, haar en accessoires.", stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.", avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier", + avatarVariant:"Soortvariant", avatarVariant_rex:"T-rex", avatarVariant_triceratops:"Triceratops", avatarVariant_stegosaurus:"Stegosaurus", avatarVariant_raptor:"Velociraptor", + avatarVariant_horned:"Gehoornd monster", avatarVariant_cyclops:"Cycloop", avatarVariant_slime:"Slijmmonster", avatarVariant_alien:"Ruimtemonster", + avatarVariant_cat:"Kat", avatarVariant_dog:"Hond", avatarVariant_rabbit:"Konijn", avatarVariant_bear:"Beer", avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl", avatarHair_none:"Geen", avatarHair_tuft:"Kuifje", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_wavy:"Golvend", avatarHair_bob:"Boblijn", avatarHair_ponytail:"Staartje", avatarHair_braids:"Vlechten", avatarHair_mohawk:"Hanenkam", avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire", @@ -549,9 +552,12 @@ const I18N = { "brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold", wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.", complimentsDraw:"Draw a compliment", - wg_avatar:"My character", wg_avatar_d:"Build your own character: species, colours, hair and accessories.", + wg_avatar:"My character", wg_avatar_d:"Choose a dinosaur, monster or animal, then its variant, colours, hair and accessories.", stAvatar:"My character", stAvatarSub:"Build your own character.", avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal", + avatarVariant:"Creature variant", avatarVariant_rex:"T. rex", avatarVariant_triceratops:"Triceratops", avatarVariant_stegosaurus:"Stegosaurus", avatarVariant_raptor:"Velociraptor", + avatarVariant_horned:"Horned monster", avatarVariant_cyclops:"Cyclops", avatarVariant_slime:"Slime monster", avatarVariant_alien:"Alien monster", + avatarVariant_cat:"Cat", avatarVariant_dog:"Dog", avatarVariant_rabbit:"Rabbit", avatarVariant_bear:"Bear", avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style", avatarHair_none:"None", avatarHair_tuft:"Tuft", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_wavy:"Wavy", avatarHair_bob:"Bob", avatarHair_ponytail:"Ponytail", avatarHair_braids:"Braids", avatarHair_mohawk:"Mohawk", avatarAccHead:"Head accessory", avatarAccBody:"Body accessory", diff --git a/src/api.js b/src/api.js index 02756a8..bf790a0 100644 --- a/src/api.js +++ b/src/api.js @@ -195,6 +195,11 @@ export default async function api(app) { // Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven, // geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij. const AVATAR_SPECIES = ['dino', 'monster', 'animal']; + const AVATAR_VARIANTS = { + dino: ['rex', 'triceratops', 'stegosaurus', 'raptor'], + monster: ['horned', 'cyclops', 'slime', 'alien'], + animal: ['cat', 'dog', 'rabbit', 'bear'], + }; const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk']; const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat']; const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings']; @@ -203,13 +208,16 @@ export default async function api(app) { need(req, reply); const b = req.body ?? {}; if (!AVATAR_SPECIES.includes(b.species)) return fail(reply, 400, 'ongeldige soort'); + const variantOptions = AVATAR_VARIANTS[b.species]; + const speciesVariant = b.speciesVariant ?? variantOptions[0]; + if (!variantOptions.includes(speciesVariant)) return fail(reply, 400, 'ongeldige soortvariant'); if (!isHexColor(b.bodyColor)) return fail(reply, 400, 'ongeldige lichaamskleur'); if (!AVATAR_HAIR.includes(b.hairStyle)) return fail(reply, 400, 'ongeldige haarstijl'); if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur'); if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire'); if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire'); const avatar = { - species: b.species, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor, + species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor, accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody, }; await pool.query('UPDATE users SET avatar_data = $1 WHERE id = $2', [avatar, req.user.id]); diff --git a/test/avatar.test.js b/test/avatar.test.js index 4400b4c..8c50713 100644 --- a/test/avatar.test.js +++ b/test/avatar.test.js @@ -20,13 +20,14 @@ async function loadAvatarRenderer(){ }, }; runInNewContext(source.slice(0,cutoff)+ - ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC};', + ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC};', context); return context.__avatar; } const base={ species:'dino', + speciesVariant:'rex', bodyColor:'#3fae6a', hairStyle:'none', hairColor:'#5b3a29', @@ -34,30 +35,63 @@ const base={ accessoryBody:'none', }; -test('avatars combineren een grote kop met een compact soortspecifiek lijf',async()=>{ +test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async()=>{ const api=await loadAvatarRenderer(); - const bodies={}; - for(const species of ['dino','monster','animal']){ - const markup=api.avatarBodyMarkup(species,base.bodyColor); - bodies[species]=markup; - assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+' mist twee armen'); - assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+' mist twee benen'); - for(const part of ['av-neck','av-torso-'+species,'av-head-large','av-eye']){ - assert.ok(markup.includes(part),species+' mist '+part); + const variants={ + dino:["rex","triceratops","stegosaurus","raptor"], + monster:["horned","cyclops","slime","alien"], + animal:["cat","dog","rabbit","bear"], + }; + for(const [species,ids] of Object.entries(variants)){ + assert.deepEqual(Array.from(api.variants[species]),ids); + const bodies=[]; + for(const id of ids){ + const markup=api.avatarBodyMarkup(species,base.bodyColor,id); + bodies.push(markup); + assert.ok(markup.includes("av-creature-"+id),species+"/"+id+" mist variantklasse"); + assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+"/"+id+" mist twee armen"); + assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+"/"+id+" mist twee benen"); + for(const part of ["av-neck","av-torso-"+species,"av-head-large","av-eye"]){ + assert.ok(markup.includes(part),species+"/"+id+" mist "+part); + } + assert.ok(markup.indexOf("av-arm")api.avatarBodyMarkup(species,base.bodyColor,id); + assert.ok(markup("dino","rex").includes("av-tail-rex")); + assert.ok(markup("dino","triceratops").includes("av-frill-triceratops")); + assert.ok(markup("dino","triceratops").includes("av-nose-horn")); + assert.ok(markup("dino","stegosaurus").includes("av-dino-plates")); + assert.ok(markup("dino","raptor").includes("av-feather-crest-raptor")); + assert.ok(markup("dino","raptor").includes("av-feather-chest")); + + assert.ok(markup("monster","horned").includes("av-horn-left")); + assert.equal((markup("monster","cyclops").match(/class="av-part av-eye/g)||[]).length,1); + assert.ok(markup("monster","cyclops").includes("av-cyclops-eye")); + assert.ok(markup("monster","slime").includes("av-slime-drop")); + assert.equal((markup("monster","alien").match(/av-alien-eye/g)||[]).length,3); + assert.ok(markup("monster","alien").includes("av-antenna-tip")); + + assert.ok(markup("animal","cat").includes("av-whiskers")); + assert.ok(markup("animal","dog").includes("av-ear-dog")); + assert.ok(markup("animal","dog").includes("av-tongue")); + assert.ok(markup("animal","rabbit").includes("av-ear-rabbit")); + assert.ok(markup("animal","rabbit").includes("av-rabbit-teeth")); + assert.ok(markup("animal","bear").includes("av-ear-bear")); + assert.ok(markup("animal","bear").includes("av-muzzle-bear")); + + assert.equal(api.sanitize({species:"dino"}).speciesVariant,"rex"); + assert.equal(api.sanitize({species:"monster"}).speciesVariant,"horned"); + assert.equal(api.sanitize({species:"animal"}).speciesVariant,"cat"); + assert.equal(api.sanitize({species:"animal",speciesVariant:"rabbit"}).speciesVariant,"rabbit"); + assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned"); +}); test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{ const api=await loadAvatarRenderer(); assert.deepEqual(Array.from(api.hairIds),["none","tuft","spiky","curly","wavy","bob","ponytail","braids","mohawk"]); @@ -128,6 +162,14 @@ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async const markup=api.renderAvatarFace({...base,accessoryHead:id},'lg').innerHTML; assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort op de kop'); } + const cyclopsGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"glasses"},"lg").innerHTML; + const cyclopsPatch=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"eyepatch"},"lg").innerHTML; + const alienGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"glasses"},"lg").innerHTML; + const alienShades=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"sunglasses"},"lg").innerHTML; + assert.ok(cyclopsGlasses.includes("av-glasses-cyclops"),"cycloopbril hoort rond het ene oog"); + assert.ok(cyclopsPatch.includes("av-eyepatch-cyclops"),"cycloopooglap hoort voor het ene oog"); + assert.ok(alienGlasses.includes("av-glasses-alien"),"alienbril hoort rond drie ogen"); + assert.ok(alienShades.includes("av-sunglasses-alien"),"alienzonnebril hoort alle drie ogen af te dekken"); const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML; assert.ok(cape.indexOf('av-cape"'){ +test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{ const api=await loadAvatarRenderer(); let combinations=0; - for(const speciesId of ['dino','monster','animal']){ - for(const hairId of api.hairIds){ - for(const headId of api.headIds){ - for(const bodyId of api.bodyIds){ - const svg=api.renderAvatarFace({ - ...base, - species:speciesId, - hairStyle:hairId, - accessoryHead:headId, - accessoryBody:bodyId, - },'sm'); - assert.equal(svg.attributes.viewBox,'-8 -21 116 132'); - assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet'); - assert.doesNotMatch(svg.innerHTML,/undefined|NaN/); - assert.ok(svg.innerHTML.includes('av-head-large')); - combinations++; + for(const speciesId of ["dino","monster","animal"]){ + for(const variantId of api.variants[speciesId]){ + for(const hairId of api.hairIds){ + for(const headId of api.headIds){ + for(const bodyId of api.bodyIds){ + const svg=api.renderAvatarFace({ + ...base, + species:speciesId, + speciesVariant:variantId, + hairStyle:hairId, + accessoryHead:headId, + accessoryBody:bodyId, + },"sm"); + assert.equal(svg.attributes.viewBox,"-8 -21 116 132"); + assert.equal(svg.attributes.preserveAspectRatio,"xMidYMid meet"); + assert.doesNotMatch(svg.innerHTML,/undefined|NaN/); + assert.ok(svg.innerHTML.includes("av-head-large")); + assert.ok(svg.innerHTML.includes("av-creature-"+variantId)); + combinations++; + } } } } } - assert.equal(combinations,3240); + assert.equal(combinations,12960); }); - -test('client, API en beide talen bevatten dezelfde nieuwe avataropties',async()=>{ - const [api,core]=await Promise.all([ - readFile('src/api.js','utf8'), - readFile('public/js/core.js','utf8'), +test("client, API en beide talen bevatten dezelfde avataropties",async()=>{ + const [apiSource,core]=await Promise.all([ + readFile("src/api.js","utf8"), + readFile("public/js/core.js","utf8"), ]); - const hair=['tuft','wavy','bob','braids']; - const accessories=['beanie','wizardhat','eyepatch','flower','bowtie','belt','satchel','wings']; + const hair=["tuft","wavy","bob","braids"]; + const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings"]; + const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"]; for(const id of hair){ - assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie'); - assert.equal(core.split('avatarHair_'+id+':').length-1,2,id+' mist een vertaling'); + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); + assert.equal(core.split("avatarHair_"+id+":").length-1,2,id+" mist een vertaling"); } for(const id of accessories){ - assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie'); - assert.equal(core.split('avatarAcc_'+id+':').length-1,2,id+' mist een vertaling'); + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); + assert.equal(core.split("avatarAcc_"+id+":").length-1,2,id+" mist een vertaling"); } + for(const id of variants){ + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-variantvalidatie"); + assert.equal(core.split("avatarVariant_"+id+":").length-1,2,id+" mist een variantvertaling"); + } + assert.ok(apiSource.includes("speciesVariant"),"API slaat de soortvariant niet op"); + assert.equal(core.split("avatarVariant:").length-1,2,"variantveld mist een label"); }); diff --git a/test/save-system.test.js b/test/save-system.test.js index ea4d7be..f63a031 100644 --- a/test/save-system.test.js +++ b/test/save-system.test.js @@ -38,6 +38,39 @@ function makeApp({ dataRev = 3, updateSucceeds = true } = {}) { const sessionCookie = { teach_session: 'x'.repeat(64) }; +const avatarPayload = { + species: "animal", speciesVariant: "rabbit", bodyColor: "#aabbcc", + hairStyle: "wavy", hairColor: "#553311", accessoryHead: "glasses", accessoryBody: "scarf", +}; + +test("avatar-API bewaart een geldige variant binnen de gekozen categorie", async () => { + const { app, calls } = await makeApp(); + const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: avatarPayload }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().avatar.speciesVariant, "rabbit"); + const update = calls.find((c) => c.sql.startsWith("UPDATE users SET avatar_data")); + assert.equal(update.params[0].species, "animal"); + assert.equal(update.params[0].speciesVariant, "rabbit"); + await app.close(); +}); + +test("avatar-API geeft oudere clients een standaardvariant", async () => { + const { app } = await makeApp(); + const { speciesVariant, ...legacyPayload } = avatarPayload; + const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: legacyPayload }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().avatar.speciesVariant, "cat"); + await app.close(); +}); + +test("avatar-API weigert een variant uit een andere categorie", async () => { + const { app, calls } = await makeApp(); + const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: { ...avatarPayload, speciesVariant: "rex" } }); + assert.equal(res.statusCode, 400, res.body); + assert.equal(res.json().error, "ongeldige soortvariant"); + assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false); + await app.close(); +}); test('GET /me/data geeft data én rev terug', async () => { const { app } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });