diff --git a/backend/docs/falukant-character-appearance.md b/backend/docs/falukant-character-appearance.md new file mode 100644 index 0000000..e07b06d --- /dev/null +++ b/backend/docs/falukant-character-appearance.md @@ -0,0 +1,38 @@ +# Dauerhafte Charakterfarben + +`falukant_data.character` enthält `hair_color` und `eye_color` als `VARCHAR(7)`. +Die Sequelize- und API-Felder heißen `hairColor` und `eyeColor`. +Erlaubt sind sechsstellige Hex-Farben einschließlich `#`, z. B. `#69432d`. + +Migration: `20261104000001-add-character-appearance.cjs`. +Im Backend-Verzeichnis mit `npm run db:migrate` ausführen, bevor der neue +Backend-Code startet. Die Migration wird hier nur in einer isolierten +Testdatenbank ausgeführt; die produktive Datenbank wird nicht verändert. + +Die Migration vergibt für bestehende Charaktere einmalig Farben aus einer +Palette mit sieben Haar- und sechs Augenfarben. Bereits vorhandene Werte +bleiben erhalten, auch beim erneuten Ausführen der Migration. Ein separater +DB-Trigger vergibt Farben für neue Charaktere, einschließlich roher INSERTs +des Rust-Daemons für NPCs und Neugeborene. NULL-Werte bei Updates behalten die +bisherige Farbe. Andere explizite, gültige Hex-Farben sind ebenfalls zulässig. +Die Farben ändern sich beim Altern nicht automatisch. Vererbung und Ergrauen +sind noch nicht implementiert; die aktuelle Palette ist eine Spielverteilung. + +Familie (Partner, Kinder, Liebschaften), Taufliste, Direktoren sowie politische +und kirchliche Amtsträger liefern die Farben an die vorhandene 3D-Anzeige. +Die Spielerdaten und das Dashboard liefern sie ebenfalls. Die 3D-Komponenten +verwenden die gespeicherten Farben nur in den markierten Haar-/Irisflächen. +Unmarkierte Altersmodelle und die 2D-Porträts behalten ihre Originaltexturen. +Der Modell-Editor bleibt eine Vorschau ohne Schreibzugriff auf Charakterdaten. + +Prüfung mit einer ausdrücklich bereitgestellten, wegwerfbaren Datenbank: + +```bash +TEST_DATABASE_URL='postgresql://…/yourpart_development_test' \ + node --test tools/characterAppearance.test.mjs +``` + +Der Integrationstest prüft Bestandsdaten, wiederholte und teilweise manuell +vorbereitete Migrationen, beide DB-Trigger zusammen, rohe INSERTs, +Sequelize-Erstellung/-Updates, Farbvalidierung und Rollback. Er verwirft +das Schema `falukant_data` ausschließlich in `yourpart_development_test`. diff --git a/backend/migrations-active/20261104000001-add-character-appearance.cjs b/backend/migrations-active/20261104000001-add-character-appearance.cjs new file mode 100644 index 0000000..0cde5cf --- /dev/null +++ b/backend/migrations-active/20261104000001-add-character-appearance.cjs @@ -0,0 +1,75 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(` + ALTER TABLE falukant_data.character + ADD COLUMN IF NOT EXISTS hair_color VARCHAR(7), + ADD COLUMN IF NOT EXISTS eye_color VARCHAR(7); + + CREATE OR REPLACE FUNCTION falukant_data.initialize_character_appearance() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'UPDATE' THEN + NEW.hair_color := COALESCE(NEW.hair_color, OLD.hair_color); + NEW.eye_color := COALESCE(NEW.eye_color, OLD.eye_color); + END IF; + IF NEW.hair_color IS NULL THEN + NEW.hair_color := (ARRAY[ + '#211b18', '#493024', '#69432d', '#95603c', '#c69c60', '#d8bf83', '#a34826' + ])[1 + floor(random() * 7)::int]; + END IF; + IF NEW.eye_color IS NULL THEN + NEW.eye_color := (ARRAY[ + '#493026', '#795532', '#8b7544', '#56704c', '#497c9c', '#75858b' + ])[1 + floor(random() * 6)::int]; + END IF; + RETURN NEW; + END; + $$; + + DROP TRIGGER IF EXISTS initialize_character_appearance ON falukant_data.character; + CREATE TRIGGER initialize_character_appearance + BEFORE INSERT OR UPDATE ON falukant_data.character + FOR EACH ROW EXECUTE FUNCTION falukant_data.initialize_character_appearance(); + + UPDATE falukant_data.character SET hair_color = hair_color + WHERE hair_color IS NULL OR eye_color IS NULL; + + ALTER TABLE falukant_data.character + ALTER COLUMN hair_color SET NOT NULL, + ALTER COLUMN eye_color SET NOT NULL; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'falukant_data.character'::regclass + AND conname = 'character_appearance_colors_valid' + ) THEN + ALTER TABLE falukant_data.character + ADD CONSTRAINT character_appearance_colors_valid CHECK ( + hair_color ~ '^#[0-9a-fA-F]{6}$' + AND eye_color ~ '^#[0-9a-fA-F]{6}$' + ); + END IF; + END; + $$; + `, { transaction }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS initialize_character_appearance ON falukant_data.character; + DROP FUNCTION IF EXISTS falukant_data.initialize_character_appearance(); + ALTER TABLE falukant_data.character + DROP CONSTRAINT IF EXISTS character_appearance_colors_valid, + DROP COLUMN IF EXISTS hair_color, + DROP COLUMN IF EXISTS eye_color; + `, { transaction }); + }); + }, +}; diff --git a/backend/models/falukant/data/character.js b/backend/models/falukant/data/character.js index d472ca9..45e8b92 100755 --- a/backend/models/falukant/data/character.js +++ b/backend/models/falukant/data/character.js @@ -23,6 +23,17 @@ FalukantCharacter.init( defaultValue: DataTypes.NOW}, gender: { type: DataTypes.STRING}, + // The DB trigger initializes these for Sequelize and raw daemon inserts. + hairColor: { + type: DataTypes.STRING(7), + allowNull: true, + validate: { is: /^#[0-9a-fA-F]{6}$/ }, + }, + eyeColor: { + type: DataTypes.STRING(7), + allowNull: true, + validate: { is: /^#[0-9a-fA-F]{6}$/ }, + }, // Initialized once by the DB trigger, also for raw daemon INSERTs. // Nullable here so Sequelize lets omitted values reach that trigger; // the database enforces NOT NULL and the ordering of both ages. diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 6811326..90be873 100755 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -980,7 +980,7 @@ class FalukantService extends BaseService { { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id', 'level'] } ], // reputation: für Adel-Voraussetzungen u.a. (checkReputationRequirement nutzt user.character ohne Nachladen) - attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation', 'pubertyStartAge', 'fertileFromAge'] + attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'] }, ] }); @@ -1028,7 +1028,7 @@ class FalukantService extends BaseService { { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'] } ], - attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility'] + attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility', 'hairColor', 'eyeColor'] }, { model: RegionData, @@ -1142,7 +1142,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'], + attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, @@ -2944,7 +2944,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'], + attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender', 'hairColor', 'eyeColor'], where: { regionId }, include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName' }, @@ -3287,6 +3287,8 @@ class FalukantService extends BaseService { age, knowledge, gender: proposal.character.gender, + hairColor: proposal.character.hairColor ?? null, + eyeColor: proposal.character.eyeColor ?? null, }, }; }); @@ -3370,7 +3372,7 @@ class FalukantService extends BaseService { as: 'character', // Wichtige Felder explizit auswählen, damit die Joins auf Titel, // Namen und Region im äußeren Select funktionieren. - attributes: ['id', 'birthdate', 'gender', 'titleOfNobility', 'firstName', 'lastName', 'regionId'], + attributes: ['id', 'birthdate', 'gender', 'titleOfNobility', 'firstName', 'lastName', 'regionId', 'hairColor', 'eyeColor'], where: { regionId: branch.regionId, }, @@ -3434,6 +3436,8 @@ class FalukantService extends BaseService { title: director.character.nobleTitle.labelTr, age, gender: director.character.gender, + hairColor: director.character.hairColor ?? null, + eyeColor: director.character.eyeColor ?? null, nobleTitle: director.character.nobleTitle, definedFirstName: director.character.definedFirstName, definedLastName: director.character.definedLastName, @@ -3463,7 +3467,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'character', - attributes: ['id', 'birthdate', 'gender'], + attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'], include: [ { model: TitleOfNobility, @@ -3632,7 +3636,7 @@ class FalukantService extends BaseService { RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }), FalukantCharacter.findAll({ where: { id: char2Ids }, - attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge'], + attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, @@ -3675,6 +3679,8 @@ class FalukantService extends BaseService { age: calcAge(c2.birthdate), development: getCharacterDevelopment(c2, calcAge(c2.birthdate)), gender: c2.gender, + hairColor: c2.hairColor ?? null, + eyeColor: c2.eyeColor ?? null, firstName: c2.definedFirstName?.name || 'Unknown', nobleTitle: c2.nobleTitle?.labelTr || '', mood: c2.mood, @@ -3720,7 +3726,7 @@ class FalukantService extends BaseService { const childChars = childCharIds.length ? await FalukantCharacter.findAll({ where: { id: childCharIds }, - attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'], + attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] }) : []; @@ -3781,6 +3787,8 @@ class FalukantService extends BaseService { childCharacterId: rel.childCharacterId, name: kid?.definedFirstName?.name || 'Unknown', gender: kid?.gender, + hairColor: kid?.hairColor ?? null, + eyeColor: kid?.eyeColor ?? null, age: kid?.birthdate ? calcAge(kid.birthdate) : null, development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null), hasName: rel.nameSet, @@ -3857,6 +3865,8 @@ class FalukantService extends BaseService { name: partner.firstName || 'Unknown', age: partner.age != null ? partner.age : null, gender: partner.gender || null, + hairColor: partner.hairColor ?? null, + eyeColor: partner.eyeColor ?? null, title: partner.nobleTitle || '', role: state.loverRole || 'lover', affection: state.affection, @@ -4451,7 +4461,7 @@ class FalukantService extends BaseService { const loadCandidates = async (where) => FalukantCharacter.findAll({ where, include: includes, - attributes: ['id', 'birthdate', 'gender'], + attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'], order: sequelize.random(), limit: 10 }); @@ -4522,7 +4532,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'proposedCharacter', - attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate'], + attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, @@ -5774,6 +5784,8 @@ class FalukantService extends BaseService { return { id: child.child.id, gender: child.child.gender, + hairColor: child.child.hairColor ?? null, + eyeColor: child.child.eyeColor ?? null, age: calcAge(child.child.birthdate), proposedFirstName: child.child.definedFirstName.name, }; @@ -6824,7 +6836,7 @@ class FalukantService extends BaseService { { model: FalukantCharacter, as: 'holder', - attributes: ['id', 'gender', 'birthdate'], + attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, @@ -6906,6 +6918,8 @@ class FalukantService extends BaseService { definedLastName: o.holder.definedLastName, nobleTitle: o.holder.nobleTitle, gender: o.holder.gender, + hairColor: o.holder.hairColor ?? null, + eyeColor: o.holder.eyeColor ?? null, age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null } : null, @@ -6975,7 +6989,7 @@ class FalukantService extends BaseService { include: [{ model: FalukantCharacter, as: 'character', - attributes: ['birthdate', 'gender'], + attributes: ['birthdate', 'gender', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, @@ -7890,7 +7904,7 @@ ORDER BY r.id`, { model: FalukantCharacter, as: 'character', - attributes: ['id', 'birthdate', 'gender'], + attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'], include: [ { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, @@ -7929,6 +7943,8 @@ ORDER BY r.id`, titleLabelTr: titleLabelTr || null, nameWithoutTitle, gender: character.gender ?? null, + hairColor: character.hairColor ?? null, + eyeColor: character.eyeColor ?? null, age, money: Number(falukantUser.money ?? 0), unreadNotificationsCount, @@ -8404,7 +8420,7 @@ ORDER BY r.id`, { model: FalukantCharacter, as: 'holder', - attributes: ['id', 'gender', 'birthdate'], + attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'], required: false, include: [ { @@ -8470,6 +8486,8 @@ ORDER BY r.id`, id: o.holder.id, name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(), gender: o.holder.gender, + hairColor: o.holder.hairColor ?? null, + eyeColor: o.holder.eyeColor ?? null, title: o.holder.nobleTitle?.labelTr, age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null } diff --git a/backend/tools/characterAppearance.test.mjs b/backend/tools/characterAppearance.test.mjs new file mode 100644 index 0000000..74fd850 --- /dev/null +++ b/backend/tools/characterAppearance.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Sequelize, DataTypes } from 'sequelize'; +import migration from '../migrations-active/20261104000001-add-character-appearance.cjs'; +import developmentMigration from '../migrations-active/20261104000000-add-character-development-ages.cjs'; + +// Only the explicitly supplied disposable database may be changed. +test('appearance migration persists colors for existing characters, Sequelize and raw daemon inserts', { + skip: !process.env.TEST_DATABASE_URL, +}, async () => { + const db = new Sequelize(process.env.TEST_DATABASE_URL, { logging: false }); + try { + const [[{ name }]] = await db.query('SELECT current_database() AS name'); + assert.equal(name, 'yourpart_development_test', 'Use the isolated development test database'); + await db.query(` + DROP SCHEMA IF EXISTS falukant_data CASCADE; + CREATE SCHEMA falukant_data; + CREATE TABLE falukant_data.character ( + id SERIAL PRIMARY KEY, gender TEXT, health INT DEFAULT 100, + hair_color VARCHAR(7), updated_at TIMESTAMPTZ DEFAULT NOW() + ); + INSERT INTO falukant_data.character (gender, hair_color) + VALUES ('female', '#AbC123'), ('male', NULL); + `); + const qi = db.getQueryInterface(); + await developmentMigration.up(qi); + const [developmentBefore] = await db.query('SELECT id, health, puberty_start_age, fertile_from_age, updated_at FROM falukant_data.character ORDER BY id'); + await migration.up(qi); + const [developmentAfter] = await db.query('SELECT id, health, puberty_start_age, fertile_from_age, updated_at FROM falukant_data.character ORDER BY id'); + assert.deepEqual(developmentAfter, developmentBefore, 'Existing character development remains intact'); + const [before] = await db.query('SELECT * FROM falukant_data.character ORDER BY id'); + assert.equal(before[0].hair_color, '#AbC123', 'Keep manually prepared values'); + for (const row of before) { + assert.match(row.hair_color, /^#[0-9a-fA-F]{6}$/); + assert.match(row.eye_color, /^#[0-9a-fA-F]{6}$/); + } + await migration.up(qi); + await db.query(`UPDATE falukant_data.character SET hair_color = NULL, eye_color = NULL`); + const [after] = await db.query('SELECT * FROM falukant_data.character ORDER BY id'); + assert.deepEqual(after, before, 'Repeated migrations and omitted colors keep existing assignments'); + + // NPCs and newborns created with raw Rust SQL also get stable colors. + const [generated] = await db.query(`INSERT INTO falukant_data.character (gender) + SELECT CASE WHEN i % 2 = 0 THEN 'female' ELSE 'male' END + FROM generate_series(1, 1000) i RETURNING hair_color, eye_color`); + assert.equal(new Set(generated.map(c => c.hair_color)).size, 7); + assert.equal(new Set(generated.map(c => c.eye_color)).size, 6); + assert.ok(generated.every(c => /^#[0-9a-fA-F]{6}$/.test(c.hair_color) && /^#[0-9a-fA-F]{6}$/.test(c.eye_color))); + + const Character = db.define('AppearanceTestCharacter', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + gender: DataTypes.STRING, + hairColor: { type: DataTypes.STRING(7), field: 'hair_color' }, + eyeColor: { type: DataTypes.STRING(7), field: 'eye_color' }, + }, { schema: 'falukant_data', tableName: 'character', timestamps: false }); + const character = await Character.create({ gender: 'female', eyeColor: '#123456' }); + assert.match(character.hairColor, /^#[0-9a-fA-F]{6}$/); + assert.equal(character.eyeColor, '#123456'); + const assigned = character.hairColor; + await character.update({ gender: 'male' }); + await character.reload(); + assert.equal(character.hairColor, assigned); + await character.update({ hairColor: '#654321' }); + await character.reload(); + assert.equal(character.hairColor, '#654321'); + assert.equal(character.eyeColor, '#123456'); + for (const color of ['red', '#fff', '#12345z', '']) { + await assert.rejects(db.query('UPDATE falukant_data.character SET eye_color = :color WHERE id = 1', { + replacements: { color }, + })); + } + + const [[{ count: oldCount }]] = await db.query('SELECT count(*)::int FROM falukant_data.character'); + await migration.down(qi); + const [[{ count: newCount }]] = await db.query('SELECT count(*)::int FROM falukant_data.character'); + assert.equal(newCount, oldCount, 'Rollback retains characters'); + await migration.up(qi); + } finally { + await db.close(); + } +}); diff --git a/frontend/docs/falukant-character-appearance.md b/frontend/docs/falukant-character-appearance.md index ff6fd30..d2c099e 100644 --- a/frontend/docs/falukant-character-appearance.md +++ b/frontend/docs/falukant-character-appearance.md @@ -42,7 +42,12 @@ da solche Dateien vom Quellmodell-Build übersprungen werden. Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige Markierung aller Modellvarianten ist eine einmalige Aufgabe je Modell. Es gibt -keine neue Datenbankmigration oder Speicherung pro Spielcharakter. +keine separate Speicherung der Markierungen pro Spielcharakter. + +Die Charakterfarben selbst werden inzwischen als `hairColor` und `eyeColor` +in den Charakterdaten gespeichert und an die Spielansichten weitergegeben. +Siehe `backend/docs/falukant-character-appearance.md` für Migration und +Initialisierung. Der Editor ändert weiterhin nur Modelle und Vorschaufarben. ## Schnittstelle für die Anzeige diff --git a/frontend/src/components/falukant/DirectorInfo.vue b/frontend/src/components/falukant/DirectorInfo.vue index cf17ce3..846de1e 100755 --- a/frontend/src/components/falukant/DirectorInfo.vue +++ b/frontend/src/components/falukant/DirectorInfo.vue @@ -11,6 +11,7 @@
@@ -341,6 +342,7 @@ :style="getChild3dBackgroundStyle(child)" >