fix(state): changing species clears language, citizenship, and subspecies selections

This commit is contained in:
lew 2026-03-24 02:33:35 +00:00
parent 48f8b46827
commit c220815b89
20 changed files with 172 additions and 104 deletions

View file

@ -2,8 +2,10 @@
import { roster } from '$lib/state.svelte';
import { slugify } from '$lib/utils/slugify';
function displayName(char: { data: Record<string, unknown> }): string {
const name = char.data[slugify('Name')];
function displayName(char: { template: { records: { fields: { type: string; label: string }[] }[] }; data: Record<string, unknown> }): string {
const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
const key = nameField ? slugify(nameField.label) : slugify('Name');
const name = char.data[key];
return (name as string) || 'Unnamed Character';
}
</script>

View file

@ -46,7 +46,9 @@
function displayName(): string {
const char = roster.active;
if (!char) return '';
const name = char.data[slugify('Name')];
const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
const key = nameField ? slugify(nameField.label) : slugify('Name');
const name = char.data[key];
return (name as string) || 'Unnamed Character';
}

View file

@ -63,7 +63,9 @@
function charName(): string {
if (!charData) return 'Unknown';
return (charData.data[slugify('Name')] as string) || 'Unnamed Character';
const nameField = charData.template.records.flatMap((r: any) => r.fields).find((f: any) => f.type === 'name');
const key = nameField ? slugify(nameField.label) : slugify('Name');
return (charData.data[key] as string) || 'Unnamed Character';
}
async function importCharacter() {

View file

@ -13,8 +13,9 @@
let expanded = $state(false);
let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
let filled = $derived(
record.fields.filter((f) => {
dataFields.filter((f) => {
const v = data[slugify(f.label)];
if (v === undefined || v === null || v === '' || v === 0) return false;
if (Array.isArray(v) && v.length === 0) return false;
@ -47,7 +48,7 @@
{/if}
</div>
<span class="text-sm tabular-nums" style="color: var(--text-muted);">
{filled}/{record.fields.length}
{filled}/{dataFields.length}
</span>
</button>

View file

@ -14,6 +14,17 @@
let showTemplateSwitcher = $state(false);
let showMigrationModal = $state(false);
let speciesKeys = $derived(new Set(
character.template.records.flatMap((r) => r.fields)
.filter((f) => f.type === 'species')
.map((f) => slugify(f.label))
));
const SPECIES_DEPENDENT_TYPES = new Set(['subspecies', 'citizenship', 'languages']);
let speciesDependentKeys = $derived(new Set(
character.template.records.flatMap((r) => r.fields)
.filter((f) => SPECIES_DEPENDENT_TYPES.has(f.type))
.map((f) => slugify(f.label))
));
let speciesKey = slugify('Species');
let pendingMigration = $derived.by(() => {
@ -59,9 +70,8 @@
return null;
});
function switchTemplate(template: Template) {
character.template = $state.snapshot(template);
roster.scheduleSave(character);
async function switchTemplate(template: Template) {
await roster.migrateToPreset(character, template);
dismissed = null;
showTemplateSwitcher = false;
}
@ -143,6 +153,11 @@
data={character.data}
onFieldChange={(key, value) => {
character.data[key] = value;
if (speciesKeys.has(key)) {
for (const depKey of speciesDependentKeys) {
character.data[depKey] = '';
}
}
roster.scheduleSave(character);
}}
/>

View file

@ -1,8 +1,21 @@
<script lang="ts">
import type { CitizenshipField } from '$lib/types';
import { citizenships } from '$lib/data';
import { citizenships, species } from '$lib/data';
import { slugify } from '$lib/utils/slugify';
let { field, value = '', onChange }: { field: CitizenshipField; value: string; onChange: (v: string) => void } = $props();
let { field, value = '', onChange, data }: {
field: CitizenshipField;
value: string;
onChange: (v: string) => void;
data: Record<string, unknown>;
} = $props();
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
let filtered = $derived(
currentSpecies
? citizenships.filter((c) => currentSpecies!.citizenships.includes(c.id))
: citizenships
);
let custom = $state(false);
@ -16,7 +29,7 @@
}
}
let isCustom = $derived(custom || (value !== '' && !citizenships.some((c) => c.name === value)));
let isCustom = $derived(custom || (value !== '' && !filtered.some((c) => c.name === value)));
</script>
<label class="block">
@ -47,7 +60,7 @@
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each citizenships as c}
{#each filtered as c}
<option value={c.name}>{c.name}</option>
{/each}
<option value="__custom">Other...</option>

View file

@ -14,6 +14,7 @@
import SubspeciesField from './SubspeciesField.svelte';
import CitizenshipField from './CitizenshipField.svelte';
import LanguagesField from './LanguagesField.svelte';
import SeparatorField from './SeparatorField.svelte';
let { field, value, onChange, data }: {
field: FieldDef;
@ -23,7 +24,9 @@
} = $props();
</script>
{#if field.type === 'text'}
{#if field.type === 'name'}
<TextField field={{ ...field, type: 'text' }} {value} {onChange} />
{:else if field.type === 'text'}
<TextField {field} {value} {onChange} />
{:else if field.type === 'textarea'}
<TextareaField {field} {value} {onChange} />
@ -48,7 +51,9 @@
{:else if field.type === 'subspecies'}
<SubspeciesField {field} {value} {onChange} {data} />
{:else if field.type === 'citizenship'}
<CitizenshipField {field} {value} {onChange} />
<CitizenshipField {field} {value} {onChange} {data} />
{:else if field.type === 'languages'}
<LanguagesField {field} {value} {onChange} />
<LanguagesField {field} {value} {onChange} {data} />
{:else if field.type === 'separator'}
<SeparatorField {field} />
{/if}

View file

@ -1,16 +1,24 @@
<script lang="ts">
import type { LanguagesField } from '$lib/types';
import { languages } from '$lib/data';
import { languages, species } from '$lib/data';
import { slugify } from '$lib/utils/slugify';
import MultiSelectField from './MultiSelectField.svelte';
let { field, value = ['Tau Ceti Basic'], onChange }: {
let { field, value = [], onChange, data }: {
field: LanguagesField;
value: string[];
onChange: (v: string[]) => void;
data: Record<string, unknown>;
} = $props();
const options = languages.map((l) => ({ value: l.name, label: l.name }));
const multiField = $derived({ ...field, type: 'multi-select' as const, options });
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
let filtered = $derived(
currentSpecies
? languages.filter((l) => currentSpecies!.languages.includes(l.id))
: languages
);
let options = $derived(filtered.map((l) => ({ value: l.name, label: l.name })));
let multiField = $derived({ ...field, type: 'multi-select' as const, options });
</script>
<MultiSelectField field={multiField} {value} {onChange} />

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { SeparatorField } from '$lib/types';
let { field }: { field: SeparatorField } = $props();
</script>
{#if field.label}
<div class="flex items-center gap-3 pt-2">
<hr class="flex-1" style="border-color: var(--border);" />
<span class="text-xs font-medium uppercase tracking-wide" style="color: var(--text-muted);">{field.label}</span>
<hr class="flex-1" style="border-color: var(--border);" />
</div>
{:else}
<hr class="mt-2" style="border-color: var(--border);" />
{/if}

View file

@ -68,6 +68,7 @@ function parseField(raw: any): FieldDef {
const type = raw['@_type'];
switch (type) {
case 'name':
case 'text':
case 'textarea':
case 'date':
@ -80,6 +81,8 @@ function parseField(raw: any): FieldDef {
case 'citizenship':
case 'languages':
return { ...base, type };
case 'separator':
return { type: 'separator', label: raw['@_label'] ?? '' };
case 'number':
return {
...base,

View file

@ -1,6 +1,7 @@
import type { Character, Template } from './types';
import { pruneEmpty } from './sharing';
import { presets } from './presets';
import { slugify } from './utils/slugify';
interface CharacterFilePayload {
version: number;
@ -44,7 +45,9 @@ export function parseCharacterFile(json: string): { template: Template | Omit<Te
}
export function characterFileName(char: Character): string {
const name = char.data.name as string | undefined;
const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
const key = nameField ? slugify(nameField.label) : 'name';
const name = char.data[key] as string | undefined;
if (!name || !name.trim()) return 'character.json';
return name.trim().replace(/[^a-zA-Z0-9'-]/g, '-').replace(/-+/g, '-') + '.json';
}

View file

@ -11,6 +11,7 @@ export function formatFieldOutput(
currentSpecies?: string
): string | null {
switch (field.type) {
case 'name':
case 'text':
case 'date':
case 'citizenship':
@ -125,12 +126,32 @@ function renderFields(
speciesData?: SpeciesData[],
currentSpecies?: string
): string[] {
const lines: string[] = [];
// Split fields into groups by separator boundaries
const groups: FieldDef[][] = [[]];
for (const field of fields) {
const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
if (out) lines.push(out);
if (field.type === 'separator') {
groups.push([]);
} else {
groups[groups.length - 1].push(field);
}
}
return lines;
const rendered = groups.map((group) => {
const lines: string[] = [];
for (const field of group) {
const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
if (out) lines.push(out);
}
return lines;
});
const result: string[] = [];
for (const lines of rendered) {
if (!lines.length) continue;
if (result.length) result.push('');
result.push(...lines);
}
return result;
}
function splitLines(text: string | undefined): string[] {

View file

@ -4,6 +4,7 @@ import { slugify } from './utils/slugify';
function zodForField(field: FieldDef): z.ZodTypeAny {
switch (field.type) {
case 'name':
case 'text':
case 'textarea':
case 'list':
@ -30,6 +31,7 @@ export function buildCharacterSchema(template: Template): z.ZodObject<Record<str
const shape: Record<string, z.ZodTypeAny> = {};
for (const record of template.records) {
for (const field of record.fields) {
if (field.type === 'separator') continue;
shape[slugify(field.label)] = zodForField(field);
}
}

View file

@ -9,6 +9,14 @@ let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let statusTimer: ReturnType<typeof setTimeout> | null = null;
const SINGLETON_TYPES = new Set([
'name', 'species', 'subspecies', 'citizenship', 'languages', 'height', 'weight'
]);
function allFields(template: Template) {
return template.records.flatMap((r) => r.fields).filter((f) => f.type !== 'separator');
}
function migrateData(char: Character, preset: Template) {
for (const record of preset.records) {
for (const field of record.fields) {
@ -26,6 +34,22 @@ function migrateData(char: Character, preset: Template) {
}
}
}
const oldByType = new Map<string, string>();
for (const f of allFields(char.template)) {
if (SINGLETON_TYPES.has(f.type)) {
oldByType.set(f.type, slugify(f.label));
}
}
for (const f of allFields(preset)) {
if (!SINGLETON_TYPES.has(f.type)) continue;
const newKey = slugify(f.label);
if (char.data[newKey] !== undefined) continue;
const oldKey = oldByType.get(f.type);
if (oldKey && oldKey !== newKey && char.data[oldKey] !== undefined) {
char.data[newKey] = char.data[oldKey];
}
}
}
export const roster = {

View file

@ -9,6 +9,11 @@ export interface BaseFieldDef {
from?: string;
}
export interface NameField extends BaseFieldDef {
type: 'name';
placeholder?: string;
}
export interface TextField extends BaseFieldDef {
type: 'text';
placeholder?: string;
@ -74,7 +79,13 @@ export interface LanguagesField extends BaseFieldDef {
type: 'languages';
}
export interface SeparatorField {
type: 'separator';
label: string;
}
export type FieldDef =
| NameField
| TextField
| TextareaField
| ListField
@ -88,7 +99,8 @@ export type FieldDef =
| SpeciesField
| SubspeciesField
| CitizenshipField
| LanguagesField;
| LanguagesField
| SeparatorField;
export interface RecordDef {
type: string;

View file

@ -1,13 +1,11 @@
import type { Character } from '../types';
const DEFAULT_LANGUAGES = ['Tau Ceti Basic'];
export function isBlankCharacter(char: Character): boolean {
if (!char.data) return true;
for (const value of Object.values(char.data)) {
if (value === '' || value === undefined || value === null || value === 0) continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
if (value.length === 1 && DEFAULT_LANGUAGES.includes(value[0])) continue;
return false;
}
return false;