From a8e18b95354eabf92580cbe6fc4549ef956a00c0 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 6 Mar 2026 18:38:30 -0500 Subject: [PATCH] feat: add random appearance generator for NPC spawning Co-Authored-By: Claude Opus 4.6 --- .../__tests__/appearanceGenerator.test.ts | 25 ++++++ server/src/spawner/appearanceGenerator.ts | 81 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 server/src/spawner/__tests__/appearanceGenerator.test.ts create mode 100644 server/src/spawner/appearanceGenerator.ts diff --git a/server/src/spawner/__tests__/appearanceGenerator.test.ts b/server/src/spawner/__tests__/appearanceGenerator.test.ts new file mode 100644 index 0000000..37098ca --- /dev/null +++ b/server/src/spawner/__tests__/appearanceGenerator.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { generateRandomAppearance, SKIN_OPTIONS, ACCESSORY_OPTIONS } from '../appearanceGenerator.js'; +import { ACCESSORY_SLOTS } from '@dflike/shared'; + +describe('generateRandomAppearance', () => { + it('returns a valid skinId', () => { + const appearance = generateRandomAppearance(); + expect(SKIN_OPTIONS).toContain(appearance.skinId); + }); + + it('only uses valid accessory slots', () => { + const appearance = generateRandomAppearance(); + for (const slot of Object.keys(appearance.accessories)) { + expect(ACCESSORY_SLOTS).toContain(slot); + } + }); + + it('generates different appearances on multiple calls', () => { + const appearances = new Set(); + for (let i = 0; i < 20; i++) { + appearances.add(JSON.stringify(generateRandomAppearance())); + } + expect(appearances.size).toBeGreaterThan(1); + }); +}); diff --git a/server/src/spawner/appearanceGenerator.ts b/server/src/spawner/appearanceGenerator.ts new file mode 100644 index 0000000..5121fdc --- /dev/null +++ b/server/src/spawner/appearanceGenerator.ts @@ -0,0 +1,81 @@ +import type { Appearance, AccessorySlot } from '@dflike/shared'; +import { ACCESSORY_SLOTS } from '@dflike/shared'; + +// Skin options (from chars/baseman/accessories/sprites/skins/) +export const SKIN_OPTIONS = [ + 'shape00_skin00', 'shape00_skin01', 'shape00_skin02', + 'shape00_skin03', 'shape00_skin04', 'shape00_skin05', 'shape00_skin06', +]; + +// Accessory options per slot: array of filenames (without .png) +export const ACCESSORY_OPTIONS: Record = { + arm: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '02_c00', '02_c01', '02_c02', '02_c03', + '03_c00', '03_c01', '03_c02', '03_c03', + ], + back: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + '02_c00', '02_c01', '02_c02', '02_c03', + ], + bottom: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + '02_c00', '02_c01', '02_c02', '02_c03', + ], + chest: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + '02_c00', '02_c01', '02_c02', '02_c03', + '03_c00', '03_c01', '03_c02', '03_c03', + ], + facialHair: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + ], + feet: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + ], + haircut: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + '02_c00', '02_c01', '02_c02', '02_c03', + '03_c00', '03_c01', '03_c02', '03_c03', + ], + hat: [ + '00_c00', '00_c01', '00_c02', + '01_c00', '01_c01', '01_c02', + ], + shoulder: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + ], + waist: [ + '00_c00', '00_c01', '00_c02', '00_c03', + '01_c00', '01_c01', '01_c02', '01_c03', + ], +}; + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function generateRandomAppearance(): Appearance { + const skinId = pick(SKIN_OPTIONS); + const accessories: Partial> = {}; + + for (const slot of ACCESSORY_SLOTS) { + // Always include haircut, chest, and bottom for a reasonable look + const alwaysInclude = slot === 'haircut' || slot === 'chest' || slot === 'bottom'; + if (alwaysInclude || Math.random() < 0.4) { + const options = ACCESSORY_OPTIONS[slot]; + if (options && options.length > 0) { + accessories[slot] = pick(options); + } + } + } + + return { skinId, accessories }; +}