Item Improvements added to the homebrew manager.

This commit is contained in:
2026-06-08 16:53:54 +02:00
parent 3bafc14255
commit f9e0473b2a
11 changed files with 204 additions and 99 deletions

View File

@@ -1,4 +1,4 @@
import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CompiledCharacter, DamageType, EnchantementConfig, FeatureEquipment, FeatureID, FeatureItem, FeatureList, FeatureState, FeatureValue, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, TreeStructure, WeaponConfig, WeaponState, WeaponType, WondrousState } from "~/types/character";
import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CompiledCharacter, DamageType, ImprovementConfig, FeatureEquipment, FeatureID, FeatureItem, FeatureList, FeatureState, FeatureValue, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, TreeStructure, WeaponConfig, WeaponState, WeaponType, WondrousState, CraftingType } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json';
import proses, { preview } from "#shared/proses";
@@ -27,6 +27,7 @@ export const ALIGNMENTS = ['loyal_good', 'neutral_good', 'chaotic_good', 'loyal_
export const RESISTANCES = ['stun','bleed','poison','fear','influence','charm','possesion','precision','knowledge','instinct'] as const;
export const DAMAGE_TYPES = ['slashing', 'piercing', 'bludgening', 'magic', 'fire', 'thunder', 'cold'] as const;
export const WEAPON_TYPES = ["light", "shield", "heavy", "classic", "throw", "natural", "twohanded", "finesse", "reach", "projectile", "improvised"] as const;
export const CRAFTING_TYPES = ["crafter", "armorer", "enchanter", "brewerer"] as const;
export const defaultCharacter: Character = {
id: -1,
@@ -246,7 +247,7 @@ export const CharacterNotesValidation = z.object({
export const ItemStateValidation = z.object({
id: z.string(),
amount: z.number().min(1),
enchantments: z.array(z.string()).optional(),
improvements: z.array(z.string()).optional(),
charges: z.number().optional(),
equipped: z.boolean().optional(),
state: z.any().optional(),
@@ -336,7 +337,7 @@ export class CharacterCompiler
Object.entries(value.abilities).forEach(e => this._buffer[`abilities/${e[0]}`] = { value: 0, _dirty: true, min: -Infinity, list: [{ id: '', operation: 'add', value: e[1] }] });
value.variables.items.forEach(e => this.enchant(e));
value.variables.items.forEach(e => this.improve(e));
reactivity(() => value.variables.transformed, (v) => {
if(this._character && this._result && value.aspect && config.aspects[value.aspect])
@@ -424,25 +425,25 @@ export class CharacterCompiler
}
get power()
{
return this._character.variables.items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0) ?? 0;
return this._character.variables.items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0) ?? 0;
}
enchant(item: ItemState)
improve(item: ItemState)
{
if(item.equipped)
{
config.items[item.id]?.effects?.filter(e => e.category !== 'value' || !e.property.startsWith('item/'))?.forEach(f => this.apply(f as FeatureValue | FeatureState | FeatureList))
item.enchantments?.forEach(e => config.enchantments[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.apply(f as FeatureValue | FeatureState | FeatureList)));
item.improvements?.forEach(e => config.improvements[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.apply(f as FeatureValue | FeatureState | FeatureList)));
}
else
{
config.items[item.id]?.effects?.filter(e => e.category !== 'value' || !e.property.startsWith('item/'))?.forEach(f => this.undo(f as FeatureValue | FeatureState | FeatureList))
item.enchantments?.forEach(e => config.enchantments[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.undo(f as FeatureValue | FeatureState | FeatureList)));
item.improvements?.forEach(e => config.improvements[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.undo(f as FeatureValue | FeatureState | FeatureList)));
}
item.buffer ??= {} as Record<string, PropertySum>;
Object.keys(item.buffer).forEach(e => item.buffer![e]!.list = []);
item.enchantments?.forEach(e => (config.enchantments[e]?.effect.filter(e => e.category === 'value' && e.property.startsWith('item')) as FeatureEquipment[]).forEach(feature => {
item.improvements?.forEach(e => (config.improvements[e]?.effect.filter(e => e.category === 'value' && e.property.startsWith('item')) as FeatureEquipment[]).forEach(feature => {
const property = feature.property.substring(5);
item.buffer![property] ??= { list: [], value: 0, _dirty: true, min: -Infinity };
@@ -1438,8 +1439,9 @@ type Category = ItemConfig['category'];
type Rarity = ItemConfig['rarity'];
export const colorByRarity: Record<Rarity, string> = {
'common': 'text-light-100 dark:text-dark-100',
'uncommon': 'text-light-cyan dark:text-dark-cyan',
'rare': 'text-light-purple dark:text-dark-purple',
'uncommon': 'text-light-green dark:text-dark-green',
'rare': 'text-light-cyan dark:text-dark-cyan',
'veryrare': 'text-light-purple dark:text-dark-purple',
'legendary': 'text-light-orange dark:text-dark-orange'
}
export const weaponTypeTexts: Record<WeaponType, string> = {
@@ -1468,10 +1470,17 @@ export const categoryText: Record<Category, string> = {
};
export const rarityText: Record<Rarity, string> = {
'common': 'Commun',
'uncommon': 'Atypique',
'uncommon': 'Peu commun',
'rare': 'Rare',
'veryrare': 'Très rare',
'legendary': 'Légendaire'
};
export const craftingText: Record<CraftingType, string> = {
'crafter': 'Fabrication',
'armorer': 'Armurerie',
'enchanter': 'Enchantement',
'brewerer': 'Alchimie',
}
export const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
let result = [];
switch(item.category)
@@ -1489,13 +1498,13 @@ export const subnameFactory = (item: ItemConfig, state?: ItemState): string[] =>
result = ['Objet magique'];
break;
}
if(state && state.enchantments !== undefined && state.enchantments.length > 0) result.push('Enchanté');
if(state && state.improvements !== undefined && state.improvements.length > 0) result.push('Amélioré');
if(item.consummable) result.push('Consommable');
return result;
}
export const stateFactory = (item: ItemConfig) => {
const state = { id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: item.equippable ? false : undefined } as ItemState;
const state = { id: item.id, amount: 1, charges: item.charge, improvements: [], equipped: item.equippable ? false : undefined } as ItemState;
switch(item.category)
{
case 'armor':
@@ -2220,18 +2229,13 @@ export class CharacterSheet extends CharacterCompiler
{
const items = this.compiled.variables.items;
const panel = this.itemsPanel();
const enchant = this.enchantPanel();
const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.compiled.variables.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
edit: numberpicker({ defaultValue: this.compiled.variables.money, change: v => { this.compiled.variables.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { this.compiled.variables.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
};
const improve = this.improvePanel();
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row justify-end items-center gap-8', [
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), money.readonly ]),
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), text("TODO") ]),
]),
div('flex flex-row justify-end items-center gap-8', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.power > this.compiled.itempower }], text: () => `Puissance magique: ${this.power}/${this.compiled.itempower}` }),
@@ -2246,13 +2250,13 @@ export class CharacterSheet extends CharacterCompiler
if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
const itempower = () => (item.powercost ?? 0) + (e.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0);
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);
return foldable(() => [
markdown(getText(item.description)),
div('flex flex-row gap-1', { list: () => e.enchantments!.map(e => config.enchantments[e]).filter(e => !!e), render: (e, _c) => _c ?? floater(div(() => ['flex flex-row gap-2 border px-2 rounded-full py-px !bg-opacity-20', { 'border-accent-blue bg-accent-blue': !e.cursed, 'border-light-purple bg-light-purple dark:border-dark-purple dark:bg-dark-purple': e.cursed }], [ span('text-sm font-semibold tracking-tight', e.name), div('flex flex-row gap-1 items-center', [icon('game-icons:bolt-drop', { width: 12, height: 12 }), span('text-sm font-light', e.power)]) ]), () => [markdown(getText(e.description), undefined, { tags: { a: preview } })], { class: 'max-w-96 max-h-48 p-2', position: "right" }) }),
div('flex flex-row gap-1', { list: () => e.improvements!.map(e => config.improvements[e]).filter(e => !!e), render: (e, _c) => _c ?? floater(div(() => ['flex flex-row gap-2 border px-2 rounded-full py-px !bg-opacity-20', { 'border-accent-blue bg-accent-blue': !e.cursed, 'border-light-purple bg-light-purple dark:border-dark-purple dark:bg-dark-purple': e.cursed }], [ span('text-sm font-semibold tracking-tight', e.name), div('flex flex-row gap-1 items-center', [icon('game-icons:bolt-drop', { width: 12, height: 12 }), span('text-sm font-light', e.power)]) ]), () => [markdown(getText(e.description), undefined, { tags: { a: preview } })], { class: 'max-w-96 max-h-48 p-2', position: "right" }) }),
div('flex flex-row justify-center gap-1', [
this.character.campaign ? button(text('Partager'), () => {
@@ -2276,8 +2280,8 @@ export class CharacterSheet extends CharacterCompiler
this.saveVariables();
}, 'p-1'),
() => !item.capacity ? undefined : button(text("Enchanter"), () => {
enchant.show(e);
() => !item.capacity ? undefined : button(text("Améliorer"), () => {
improve.show(e);
}, 'px-2 text-sm h-5 box-content'),
])
], [ div('flex flex-row justify-between', [
@@ -2287,7 +2291,7 @@ export class CharacterSheet extends CharacterCompiler
return Toaster.add({ content: "Vous ne pouvez equipper qu'une seule armure à la fois.", duration: 5000, timer: true, type: 'info' }), false;
e.equipped = v;
this.enchant(e);
this.improve(e);
}, class: { container: '!w-5 !h-5' } }) : undefined,
div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),
item.category === 'armor' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:shoulder-armor', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${item.health + ((e.state as ArmorState)?.health ?? 0) - ((e.state as ArmorState)?.loss ?? 0)}/${item.health + ((e.state as ArmorState)?.health ?? 0)} (${[item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0) > 0 ? '-' + (item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0)) : undefined, item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0) > 0 ? '-' + (item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0)) + '%' : undefined].filter(e => !!e).join('/')})`) ]) :
@@ -2373,32 +2377,34 @@ export class CharacterSheet extends CharacterCompiler
container.setAttribute('data-state', 'inactive');
}};
}
enchantPanel()
improvePanel()
{
const current = reactive({
item: undefined as ItemState | undefined,
});
const restrict = (enchant: EnchantementConfig, id?: string) => {
const restrict = (improvement: ImprovementConfig, id?: string) => {
if(!id) return true;
const item = config.items[id]!;
if(!enchant.restrictions)
if(!improvement.restrictions)
return true;
if(enchant.restrictions.includes(item.category))
if(improvement.restrictions[item.category])
return true;
else if(item.category === 'armor' && enchant.restrictions.includes(`armor/${item.type}`))
else if(item.category === 'armor' && improvement.restrictions[`armor/${item.type}`])
return true;
else if(item.category === 'weapon' && item.type.some(e => enchant.restrictions!.includes(`weapon/${e}`)))
else if(item.category === 'weapon' && item.type.some(e => improvement.restrictions![`weapon/${e}`]))
return true;
else if(improvement.restrictions[item.id])
return true;
return false;
}
const itempower = () => current.item && config.items[current.item.id] !== undefined ? ((config.items[current.item.id]!.powercost ?? 0) + (current.item.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0)) : 0;
const itempower = () => current.item && config.items[current.item.id] !== undefined ? ((config.items[current.item.id]!.powercost ?? 0) + (current.item.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0)) : 0;
const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Enchantements" }),
dom("h2", { class: "text-xl font-bold", text: "Améliorations" }),
div('flex flex-row gap-8 items-center justify-end', [
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': current.item && config.items[current.item.id] !== undefined ? itempower() > (config.items[current.item.id]!.capacity ?? 0) : false }], text: () => `Puissance de l'objet: ${current.item && config.items[current.item.id] !== undefined ? itempower() : false}/${current.item ? (config.items[current.item.id]!.capacity ?? 0) : 0}` }),
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.power > this.compiled!.itempower }], text: () => `Puissance du personnage: ${this.power}/${this.compiled.itempower}` }),
@@ -2408,20 +2414,20 @@ export class CharacterSheet extends CharacterCompiler
}, "p-1"), "Fermer", "left")
])
]),
div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.enchantments).filter(e => restrict(e, current.item?.id)), render: (enchant, _c) => _c ?? foldable(() => [ markdown(getText(enchant.description)) ], [
div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.improvements).filter(e => restrict(e, current.item?.id)), render: (improve, _c) => _c ?? foldable(() => [ markdown(getText(improve.description)) ], [
div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [ span('text-lg', enchant.name) ]),
div('flex flex-row items-center gap-4', [ span('text-lg', improve.name) ]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2 gap-4', [
enchant.cursed ? span('italic text-sm text-light-purple dark:text-dark-purple', `Malédiction`) : undefined,
span('italic text-sm', `Puissance magique: ${enchant.power}`),
button(icon(() => current.item?.enchantments?.includes(enchant.id) ? 'radix-icons:minus' : 'radix-icons:plus', { width: 16, height: 16 }), () => {
const idx = current.item!.enchantments?.findIndex(e => e === enchant.id) ?? -1;
improve.cursed ? span('italic text-sm text-light-purple dark:text-dark-purple', `Malédiction`) : undefined,
span('italic text-sm', `Puissance magique: ${improve.power}`),
button(icon(() => current.item?.improvements?.includes(improve.id) ? 'radix-icons:minus' : 'radix-icons:plus', { width: 16, height: 16 }), () => {
const idx = current.item!.improvements?.findIndex(e => e === improve.id) ?? -1;
if(idx === -1)
current.item!.enchantments?.push(enchant.id);
current.item!.improvements?.push(improve.id);
else
current.item!.enchantments?.splice(idx, 1);
current.item!.improvements?.splice(idx, 1);
this.enchant(current.item!);
this.improve(current.item!);
}, 'p-1 !border-solid !border-r'),
]),
])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } })