diff --git a/src/game.ts b/src/game.ts index 860556f..53fbfb1 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,7 +1,7 @@ import { CFG, VW, VH, TAU, FONT_STACK, DIFFICULTIES, STARS } from './config'; import type { Difficulty } from './config'; import { clamp, easeOutBack, fmtTime } from './util'; -import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil } from './store'; +import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getSkinId, setSkinId } from './store'; import type { GameStats } from './store'; import { AudioManager } from './audio'; import { Input } from './input'; @@ -17,7 +17,7 @@ import type { LevelInfo } from './level-data'; import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily'; import { GhostRecorder, GhostPlayer } from './ghost'; import { drawDecor } from './decor'; -import { Sprite } from './sprite'; +import { Sprite, SKINS, skinUnlocked } from './sprite'; import type { GameCtx } from './ctx'; import type { Checkpoint } from './checkpoint'; import type { Platform } from './platform'; @@ -120,6 +120,8 @@ export class Game implements GameCtx { godMode = false; /** Cheat: rainbow Rex skin (persists across runs). */ rainbow = false; + /** Cosmetic skin id (see SKINS); Mint unlocks at 3 fossils. */ + skin: string = getSkinId(); /** Ghost race: replay the stored best run alongside the player. */ ghostOn: boolean = getGhostEnabled(); /** Fossil ids discovered so far (persistent meta-progress). */ @@ -251,6 +253,33 @@ export class Game implements GameCtx { this.audio.play('ui'); } + /** Choose a cosmetic skin (Mint needs 3 fossils). */ + selectSkin(id: string): void { + if (!skinUnlocked(id, this.fossilsFound)) { + this.audio.play('ui'); + return; + } + if (id === this.skin) return; + this.skin = id; + setSkinId(id); + if (this.player) this.player.skin = id; + if (this.ghost) this.ghost.skin = id; + this.audio.play('ui'); + } + + /** Cycle skins with [ / ] on the menu; locked skins are skipped. */ + cycleSkin(dir: 1 | -1): void { + let idx = SKINS.findIndex((s) => s.id === this.skin); + if (idx < 0) idx = 0; + for (let i = 1; i <= SKINS.length; i++) { + const cand = SKINS[(((idx + dir * i) % SKINS.length) + SKINS.length) % SKINS.length]; + if (cand.id !== this.skin && skinUnlocked(cand.id, this.fossilsFound)) { + this.selectSkin(cand.id); + return; + } + } + } + /** Advance to the next level and start a fresh run. */ nextLevel(): void { this.levelIdx = (this.levelIdx + 1) % LEVELS.length; @@ -278,6 +307,7 @@ export class Game implements GameCtx { this.player.maxHearts = this.maxHearts; this.player.hearts = this.maxHearts; this.player.rainbow = this.rainbow; + this.player.skin = this.skin; if (this.maxHeartsCheat) { this.maxHeartsCheat = false; this.player.maxHearts = Game.MAX_HEARTS_CAP; @@ -306,7 +336,10 @@ export class Game implements GameCtx { this.ghost = null; if (this.ghostOn) { const track = getGhostTrack(this.daily ? -1 : this.levelIdx, this.daily ? dailySeed() : 0); - if (track) this.ghost = new GhostPlayer(track); + if (track) { + this.ghost = new GhostPlayer(track); + this.ghost.skin = this.skin; + } } this.camera.x = 0; this.camera.shake = 0; @@ -387,6 +420,10 @@ export class Game implements GameCtx { this.toggleGhost(); return; } + if ((k === 'skinPrev' || k === 'skinNext') && this.state === 'menu') { + this.cycleSkin(k === 'skinNext' ? 1 : -1); + return; + } if (k === 'debug') { this.debug = !this.debug; return; @@ -546,6 +583,10 @@ export class Game implements GameCtx { this.addShake(2); this.burst(x, y, 22, ['#f4ecd9', '#e8dcc0', '#cbb98f', '#fff'], 'dot', 170); this.texts.push(new FloatingText(x, y - 34, 'NEW FOSSIL!', '#f4ecd9')); + if (this.fossilsFound.length === 3) { + this.addStatus('Mint Rex unlocked! Pick it on the menu', '#63e0a8'); + this.audio.play('star'); + } } else { this.burst(x, y, 10, ['#f4ecd9', '#e8dcc0'], 'dot', 130); } @@ -1498,6 +1539,7 @@ export class Game implements GameCtx { dead: false, rot: 0, rainbow: this.rainbow, + skin: this.skin, }; Sprite.drawRex(ctx, fakeP, this.time); // Title block (gently floating, theme-aware) @@ -1547,7 +1589,7 @@ export class Game implements GameCtx { 205, ); // Controls panel (left) - const cpx = 34, cpy = 232, cpw = 240, cph = 232; + const cpx = 34, cpy = 232, cpw = 240, cph = 238; this.drawInfoPanel(ctx, cpx, cpy, cpw, cph, 'CONTROLS'); const rows: Array<[string, string]> = [ ['Move', 'A / D · ← →'], @@ -1558,6 +1600,7 @@ export class Game implements GameCtx { ['Difficulty', '↑ / ↓'], ['Mute · Calm · Ghost', 'M · V · G'], ['Gamepad', 'A jump · B go'], + ['Skin', '[ / ]'], ['Debug', 'F2'], ]; ctx.font = '600 13px ' + FONT_STACK; @@ -1571,7 +1614,7 @@ export class Game implements GameCtx { ctx.fillText(keys, cpx + cpw - 20, y); }); // Quest panel (right), rows vertically centred against the controls list - const qx = VW - 274, qy = 232, qw = 240, qh = 232; + const qx = VW - 274, qy = 232, qw = 240, qh = 238; this.drawInfoPanel(ctx, qx, qy, qw, qh, 'YOUR QUEST'); const quest = [ 'Reach the glowing nest', @@ -1685,6 +1728,60 @@ export class Game implements GameCtx { ctx.font = '800 12px ' + FONT_STACK; this.drawTracked(ctx, p.label, p.x + 45, 413, 2, false); }); + // Skin picker (right of the difficulty pills) + ctx.font = '700 11px ' + FONT_STACK; + ctx.fillStyle = '#cfe0f2'; + this.drawTracked(ctx, 'REX SKIN', 686, 413, 2, false); + SKINS.forEach((s, i) => { + const sx = 746 + i * 48; + const unlocked = skinUnlocked(s.id, this.fossilsFound); + ctx.save(); + if (!unlocked) ctx.globalAlpha = 0.35; + this.roundRect(ctx, sx, 394, 40, 30, 8); + ctx.fillStyle = s.body; + ctx.fill(); + // mini Rex head: spikes + eye + ctx.fillStyle = s.dark; + for (let k = 0; k < 3; k++) { + ctx.beginPath(); + ctx.moveTo(sx + 11 + k * 5, 402); + ctx.lineTo(sx + 13.5 + k * 5, 397 + k * 1.2); + ctx.lineTo(sx + 16 + k * 5, 402); + ctx.closePath(); + ctx.fill(); + } + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(sx + 26, 407, 3.6, 0, TAU); + ctx.fill(); + ctx.fillStyle = s.line; + ctx.beginPath(); + ctx.arc(sx + 27, 407, 1.6, 0, TAU); + ctx.fill(); + ctx.restore(); + if (!unlocked) { + // padlock over locked skins + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.arc(sx + 20, 406, 3.6, Math.PI, 0); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + this.roundRect(ctx, sx + 15, 406, 10, 8, 2); + ctx.fill(); + } else if (this.skin === s.id) { + ctx.strokeStyle = '#ffd257'; + ctx.lineWidth = 2; + this.roundRect(ctx, sx + 1, 395, 38, 28, 7); + ctx.stroke(); + } + }); + if (!skinUnlocked('mint', this.fossilsFound)) { + ctx.font = '600 10px ' + FONT_STACK; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.textAlign = 'center'; + ctx.fillText('3 fossils to unlock Mint', 842, 436); + } // Bottom bar: Start + toggles (sitting on the ground strip for contrast) this.uiButtons = [ // Tappable level cards (drawn above, hit-tested here) @@ -1709,6 +1806,8 @@ export class Game implements GameCtx { }, // Tappable difficulty pills (drawn above) ...pills.map((p) => ({ x: p.x, y: 394, w: 90, h: 30, label: p.label, card: true, action: () => this.selectDifficulty(p.d) })), + // Tappable skin swatches (drawn above) + ...SKINS.map((s, i) => ({ x: 746 + i * 48, y: 394, w: 40, h: 30, label: s.name, card: true, action: () => this.selectSkin(s.id) })), { x: 38, y: 474, w: 150, h: 40, label: 'Ghost: ' + (this.ghostOn ? 'On' : 'Off') + ' · G', color: '#8fa8ba', diff --git a/src/ghost.ts b/src/ghost.ts index 877a663..7fa253c 100644 --- a/src/ghost.ts +++ b/src/ghost.ts @@ -97,6 +97,9 @@ export class GhostPlayer { return !this.finished; } + /** Cosmetic skin to replay with (set by the Game to the player's skin). */ + skin: string | null = null; + /** RexView for Sprite.drawRex (Player satisfies the same shape). */ get view(): RexView { return { @@ -113,6 +116,7 @@ export class GhostPlayer { invulnT: 0, dead: false, rot: 0, + skin: this.skin ?? undefined, }; } } diff --git a/src/input.ts b/src/input.ts index fba8907..ee7a1ab 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,4 +1,6 @@ export type GameKey = + | 'skinPrev' + | 'skinNext' | 'restart' | 'mute' | 'reducedMotion' @@ -57,6 +59,8 @@ export class Input { if (e.code === 'KeyM') this.onGameKey?.('mute'); if (e.code === 'KeyV') this.onGameKey?.('reducedMotion'); if (e.code === 'KeyG') this.onGameKey?.('ghost'); + if (e.code === 'BracketLeft') this.onGameKey?.('skinPrev'); + if (e.code === 'BracketRight') this.onGameKey?.('skinNext'); if (e.code === 'F2') { e.preventDefault(); this.onGameKey?.('debug'); diff --git a/src/player.ts b/src/player.ts index 2016bd1..0ad2433 100644 --- a/src/player.ts +++ b/src/player.ts @@ -62,6 +62,8 @@ export class Player { jumpCutPending = false; /** Konami code: rainbow skin. */ rainbow = false; + /** Cosmetic skin id (see SKINS in sprite.ts); set by the Game. */ + skin = 'classic'; private readonly game: GameCtx; constructor(x: number, y: number, game: GameCtx) { diff --git a/src/sprite.ts b/src/sprite.ts index ecfb68c..a8a1b9f 100644 --- a/src/sprite.ts +++ b/src/sprite.ts @@ -2,6 +2,39 @@ import { TAU } from './config'; import type { Enemy } from './enemy'; import type { PlayerState } from './player'; +/** Rex skin palettes. Mint unlocks after finding 3 hidden fossils. */ +export type RexSkinId = 'classic' | 'ember' | 'glacier' | 'mint'; + +export interface RexSkin { + id: RexSkinId; + name: string; + /** Main body / tail / head color. */ + body: string; + /** Legs, arms, head spikes. */ + dark: string; + /** Belly. */ + belly: string; + /** Nostril, smile, eyes. */ + line: string; +} + +export const SKINS: RexSkin[] = [ + { id: 'classic', name: 'Classic', body: '#59b25f', dark: '#4c9d52', belly: '#f7ecd4', line: '#2f6e36' }, + { id: 'ember', name: 'Ember', body: '#ef8a4c', dark: '#d9672c', belly: '#ffe9cf', line: '#8a3d16' }, + { id: 'glacier', name: 'Glacier', body: '#7cc4e8', dark: '#55a3d4', belly: '#eaf7ff', line: '#2b6e94' }, + { id: 'mint', name: 'Mint', body: '#63e0a8', dark: '#3ecf96', belly: '#eafff5', line: '#1e7a58' }, +]; + +/** Resolve an arbitrary stored id to a known skin (fallback: Classic). */ +export function getSkin(id?: string): RexSkin { + return SKINS.find((s) => s.id === id) ?? SKINS[0]; +} + +/** Mint is the fossil reward; every other skin is free. */ +export function skinUnlocked(id: string, fossilsFound: string[]): boolean { + return id !== 'mint' || fossilsFound.length >= 3; +} + /** The state fields Sprite.drawRex reads (Player satisfies this structurally). */ export interface RexView { x: number; @@ -18,8 +51,10 @@ export interface RexView { dead: boolean; /** Death-tumble rotation (radians); 0 outside the dead state. */ rot: number; - /** Konami code: cycle body colors through the rainbow. */ + /** Konami code: cycle body colors through the rainbow (overrides skin). */ rainbow?: boolean; + /** Cosmetic skin id (see SKINS); defaults to Classic. */ + skin?: string; } /** Procedural character & enemy art. */ @@ -43,10 +78,13 @@ export const Sprite = { const legB = run ? Math.sin(phase + Math.PI) * 7 : 0; const tailWag = run ? Math.sin(phase * 1.5) * 4 : Math.sin(t * 2.4) * 2; const breathe = 1 + Math.sin(t * 3) * 0.02; - // Rainbow Rex (Konami): hue-cycle the body, keep the cream belly. + // Rainbow Rex (Konami) overrides any skin: hue-cycle the body. + const skin = getSkin(p.skin); const hue = (t * 160) % 360; - const bodyC = p.rainbow ? `hsl(${hue.toFixed(0)}, 78%, 55%)` : '#59b25f'; - const darkC = p.rainbow ? `hsl(${((hue + 160) % 360).toFixed(0)}, 60%, 44%)` : '#4c9d52'; + const bodyC = p.rainbow ? `hsl(${hue.toFixed(0)}, 78%, 55%)` : skin.body; + const darkC = p.rainbow ? `hsl(${((hue + 160) % 360).toFixed(0)}, 60%, 44%)` : skin.dark; + const bellyC = p.rainbow ? '#f7ecd4' : skin.belly; + const lineC = p.rainbow ? '#2f6e36' : skin.line; // Tail (behind body) ctx.fillStyle = bodyC; @@ -71,7 +109,7 @@ export const Sprite = { ctx.ellipse(0, -20 + bob * 0.4, 15, 13 * breathe, 0, 0, TAU); ctx.fill(); // Cream belly - ctx.fillStyle = '#f7ecd4'; + ctx.fillStyle = bellyC; ctx.beginPath(); ctx.ellipse(3, -17 + bob * 0.4, 9, 8 * breathe, 0, 0, TAU); ctx.fill(); @@ -99,12 +137,12 @@ export const Sprite = { ctx.ellipse(12, hy + 4, 8.5, 6.5, 0, 0, TAU); ctx.fill(); // Nostril - ctx.fillStyle = '#2f6e36'; + ctx.fillStyle = lineC; ctx.beginPath(); ctx.arc(16, hy + 2, 1.2, 0, TAU); ctx.fill(); // Smile - ctx.strokeStyle = '#2f6e36'; + ctx.strokeStyle = lineC; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.arc(11, hy + 5, 4.5, 0.15 * Math.PI, 0.75 * Math.PI); @@ -116,7 +154,7 @@ export const Sprite = { ctx.fill(); if (dead) { // X eyes - ctx.strokeStyle = '#2f6e36'; + ctx.strokeStyle = lineC; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(2.5, hy - 6); @@ -125,13 +163,13 @@ export const Sprite = { ctx.lineTo(2.5, hy - 1); ctx.stroke(); } else if (p.state === 'hurt') { - ctx.fillStyle = '#2f6e36'; + ctx.fillStyle = lineC; ctx.beginPath(); ctx.arc(5.5, hy - 3, 2.2, 0, TAU); ctx.fill(); } else if (jump) { // wide eyes in the air - ctx.fillStyle = '#2f6e36'; + ctx.fillStyle = lineC; ctx.beginPath(); ctx.arc(6, hy - 3.5, 3.2, 0, TAU); ctx.fill(); @@ -140,7 +178,7 @@ export const Sprite = { ctx.arc(7, hy - 4.5, 1.1, 0, TAU); ctx.fill(); } else { - ctx.fillStyle = '#2f6e36'; + ctx.fillStyle = lineC; const lookY = p.vy < -50 ? -1 : 0; ctx.beginPath(); ctx.arc(6, hy - 3 + lookY, 2.8, 0, TAU); diff --git a/src/store.ts b/src/store.ts index b551a83..f779a7c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,5 +1,8 @@ import type { GhostTrack } from './ghost'; import { MIN_TRACK_POINTS } from './ghost'; +import { SKINS } from './sprite'; + +const SKIN_IDS: string[] = SKINS.map((s) => s.id); /** Lifetime play statistics shown on the main menu. */ export interface GameStats { @@ -92,6 +95,17 @@ export function findFossil(id: string): void { Store.set('tinyrex_fossils', [...found, id]); } +/** Selected Rex skin id; unknown/corrupt values fall back to Classic. */ +export function getSkinId(): string { + const v = Store.get('tinyrex_skin', null); + return v && SKIN_IDS.includes(v) ? v : 'classic'; +} + +/** Persist the selected Rex skin (validated against SKINS). */ +export function setSkinId(id: string): void { + Store.set('tinyrex_skin', SKIN_IDS.includes(id) ? id : 'classic'); +} + /** Safe localStorage wrapper (settings + best records). */ export const Store = { get(key: string, fallback: T): T { diff --git a/tests/game.test.ts b/tests/game.test.ts index 2a4c11c..b432881 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Game } from '../src/game'; import { LEVEL_DATA, LEVELS } from '../src/level-data'; -import { Store, getGhostEnabled, getFoundFossils, type GameStats } from '../src/store'; +import { Store, getGhostEnabled, getFoundFossils, getSkinId, type GameStats } from '../src/store'; +import type { RexView } from '../src/sprite'; function makeGame(): Game { const canvas = document.createElement('canvas'); @@ -371,4 +372,89 @@ describe('Fossil discoveries', () => { game.level!.reset(); expect(f.collected).toBe(false); }); + + it('announces the Mint unlock when the third fossil is unearthed', () => { + game.handleKey('primary'); + game.collectFossil(0, 0, '0:0'); + game.collectFossil(0, 0, '1:0'); + game.collectFossil(0, 0, '2:0'); + expect(game.fossilsFound).toHaveLength(3); + expect(game.status.msg).toBe('Mint Rex unlocked! Pick it on the menu'); + }); +}); + +describe('Rex skins', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('selects and persists a skin from the menu', () => { + expect(game.skin).toBe('classic'); + game.selectSkin('ember'); + expect(game.skin).toBe('ember'); + expect(getSkinId()).toBe('ember'); + // No-op when unchanged (no double SFX path to worry about) + game.selectSkin('ember'); + expect(game.skin).toBe('ember'); + }); + + it('rejects Mint until 3 fossils are found', () => { + game.selectSkin('mint'); + expect(game.skin).toBe('classic'); + expect(getSkinId()).toBe('classic'); + }); + + it('lets the player wear the chosen skin, and the ghost too', () => { + game.selectSkin('glacier'); + // Store a ghost track so a ghost is replayed on the next run + Store.set('tinyrex_ghost_0', { + date: 0, + score: 100, + time: 5, + pts: [ + { t: 0, x: 40, y: 400 }, + { t: 0.1, x: 44, y: 400 }, + { t: 0.2, x: 48, y: 400 }, + { t: 0.3, x: 52, y: 400 }, + ], + }); + game.handleKey('primary'); + expect(game.player!.skin).toBe('glacier'); + const ghost = (game as unknown as { ghost: { view: RexView } | null }).ghost; + expect(ghost).not.toBeNull(); + expect(ghost!.view.skin).toBe('glacier'); + }); + + it('cycles with the bracket keys on the menu, skipping locked skins', () => { + game.handleKey('skinNext'); + expect(game.skin).toBe('ember'); + game.handleKey('skinNext'); + expect(game.skin).toBe('glacier'); + // Mint is locked: wrap around to Classic instead + game.handleKey('skinNext'); + expect(game.skin).toBe('classic'); + game.handleKey('skinPrev'); + expect(game.skin).toBe('glacier'); + }); + + it('cycles into Mint once it is unlocked', () => { + game.fossilsFound = ['0:0', '1:0', '2:0']; + game.handleKey('skinNext'); + expect(game.skin).toBe('ember'); + game.handleKey('skinNext'); + expect(game.skin).toBe('glacier'); + game.handleKey('skinNext'); + expect(game.skin).toBe('mint'); + }); + + it('ignores skin keys while playing', () => { + game.selectSkin('ember'); + game.handleKey('primary'); + expect(game.state).toBe('playing'); + game.handleKey('skinNext'); + expect(game.skin).toBe('ember'); + }); }); diff --git a/tests/skin.test.ts b/tests/skin.test.ts new file mode 100644 index 0000000..3ff4847 --- /dev/null +++ b/tests/skin.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SKINS, getSkin, skinUnlocked, Sprite, type RexView } from '../src/sprite'; +import { Store, getSkinId, setSkinId } from '../src/store'; + +function makeView(skin?: string): RexView { + return { + x: 0, + y: 0, + w: 34, + h: 46, + facing: 1, + state: 'idle', + runPhase: 0, + vy: 0, + squashX: 1, + squashY: 1, + invulnT: 0, + dead: false, + rot: 0, + skin, + }; +} + +describe('Skin table', () => { + it('ships four skins with unique ids and full palettes', () => { + expect(SKINS).toHaveLength(4); + const ids = SKINS.map((s) => s.id); + expect(new Set(ids).size).toBe(4); + expect(ids).toEqual(['classic', 'ember', 'glacier', 'mint']); + for (const s of SKINS) { + expect(s.name.length).toBeGreaterThan(0); + for (const c of [s.body, s.dark, s.belly, s.line]) { + expect(c).toMatch(/^#[0-9a-fA-F]{6}$/); + } + } + }); + + it('resolves known ids and falls back to Classic', () => { + expect(getSkin('ember').id).toBe('ember'); + expect(getSkin('nope').id).toBe('classic'); + expect(getSkin(undefined).id).toBe('classic'); + }); + + it('gates Mint behind 3 fossils and leaves the rest free', () => { + expect(skinUnlocked('classic', [])).toBe(true); + expect(skinUnlocked('ember', [])).toBe(true); + expect(skinUnlocked('glacier', [])).toBe(true); + expect(skinUnlocked('mint', [])).toBe(false); + expect(skinUnlocked('mint', ['0:0', '1:0'])).toBe(false); + expect(skinUnlocked('mint', ['0:0', '1:0', '2:0'])).toBe(true); + }); + + it('draws every skin without crashing, rainbow overrides all', () => { + const ctx = document.createElement('canvas').getContext('2d')!; + for (const s of SKINS) { + expect(() => Sprite.drawRex(ctx, makeView(s.id), 0.25)).not.toThrow(); + } + expect(() => Sprite.drawRex(ctx, makeView('ember'), 0.5)).not.toThrow(); + const rainbow = makeView('glacier'); + rainbow.rainbow = true; + expect(() => Sprite.drawRex(ctx, rainbow, 1.25)).not.toThrow(); + }); +}); + +describe('Skin persistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults to Classic and round-trips a chosen skin', () => { + expect(getSkinId()).toBe('classic'); + setSkinId('ember'); + expect(getSkinId()).toBe('ember'); + expect(Store.get('tinyrex_skin', null)).toBe('ember'); + }); + + it('rejects unknown ids on write and read', () => { + setSkinId('rainbow'); + expect(getSkinId()).toBe('classic'); + localStorage.setItem('tinyrex_skin', 'totally-bogus'); + expect(getSkinId()).toBe('classic'); + }); +});