Speichere individuelle Haar- und Augenfarben je Charakter
All checks were successful
Deploy to production / deploy (push) Successful in 2m24s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 16:54:33 +01:00
parent dd409a6a6a
commit bc7a0cb67a
10 changed files with 250 additions and 15 deletions

View File

@@ -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`.

View File

@@ -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 });
});
},
};

View File

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

View File

@@ -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
}

View File

@@ -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();
}
});