Просмотр исходного кода

Merge branch 'feats'

* feats: (25 commits)
  Fixing build errors from merge
  Transform SkillTab to use listener behavior. Send appropriate signals from SkillLevelUpPanel
  Fix bugs in code ordering/missing unsubscribe
  Remove wildCard skills that were added in choosing, but no points were allocated towards - to keep down clutter. Fix rendering on add.
  Allow adding instantiations of wildcard skills on button press. To do this, the following changes were made to SkillLevelUpPanel: - Change to use an Ordered Map of (Skill Name) -> (GUI Element). This allows order preservation while taking advantage of the usefulness of features like computeIfAbsent. - Capture more variables as member variables to reduce lambda nesting. - Add a listener that computes/regenerates the list of components.
  Make longer-named skills use tooltip display.
  Add a button for creating a new skill from a wildcard skill. Adjust GUI elements for SkilLevelUpLine components. Use the isWildcardSkill() function instead of doing a manual check.
  Add an isWildcard property to DDSkill. Get rid of stupid Optional in DDSkillPrototype Create a skill if one that does not exist is requested.
  Add WildcardSkillLevelUpLine, which allows the handling of special skills like 'Profession'. This only adds the GUI elements, and not the manipulations required.
  Extract out interface
  Rename SkillLevelUpLine in preparation of adding a special form for '(*)'-type skills.
  Put 'all' skills into DDSkills object. TODO: Handle '(*)' skills
  Add DDCharacter to the application info for DDPropertyChooser
  Making DDSkill{,s} Observable
  Updating default behavior of DDPropertyChooser
  Fixing bug in 'maxPoints' for Skill gain.
  Add DDProperty for gaining Skills, such as through the Animal Domain.
  Removing unneeded complexity in DDPropertyChooser/DDProperty split
  Fixing equality for removal of Domain
  Add propagation of changes in DomainFeature.
  ...
Sam Jaffe 8 лет назад
Родитель
Сommit
08d375e07d
32 измененных файлов с 1085 добавлено и 387 удалено
  1. 5 5
      resources/classes/Bard.json
  2. 7 1
      resources/classes/Cleric.json
  3. 18 0
      resources/spells/domain/animal.json
  4. 15 0
      resources/spells/domain/earth.json
  5. 4 1
      resources/spells/domain/plant.json
  6. 1 0
      src/main/lombok/org/leumasjaffe/charsheet/config/Constants.java
  7. 30 8
      src/main/lombok/org/leumasjaffe/charsheet/model/DDCharacterClass.java
  8. 8 4
      src/main/lombok/org/leumasjaffe/charsheet/model/DDClass.java
  9. 3 4
      src/main/lombok/org/leumasjaffe/charsheet/model/features/DDProperty.java
  10. 45 0
      src/main/lombok/org/leumasjaffe/charsheet/model/features/DDPropertyChooser.java
  11. 58 0
      src/main/lombok/org/leumasjaffe/charsheet/model/features/GroupedBonus.java
  12. 56 0
      src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/DomainFeature.java
  13. 14 12
      src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Flat.java
  14. 25 17
      src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/PerSpellLevel.java
  15. 13 9
      src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Simple.java
  16. 57 0
      src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Skill.java
  17. 2 4
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Effect.java
  18. 11 2
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Domain.java
  19. 8 3
      src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkill.java
  20. 21 12
      src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkillPrototype.java
  21. 17 2
      src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkills.java
  22. 51 24
      src/main/lombok/org/leumasjaffe/charsheet/view/SkillTab.java
  23. 3 2
      src/main/lombok/org/leumasjaffe/charsheet/view/level/LU_FeaturesPanel.java
  24. 55 0
      src/main/lombok/org/leumasjaffe/charsheet/view/level/PropertyChoicePanel.java
  25. 4 4
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SelectSpellsPanel.java
  26. 6 8
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java
  27. 246 0
      src/main/lombok/org/leumasjaffe/charsheet/view/skills/NormalSkillLevelUpLine.java
  28. 3 246
      src/main/lombok/org/leumasjaffe/charsheet/view/skills/SkillLevelUpLine.java
  29. 46 16
      src/main/lombok/org/leumasjaffe/charsheet/view/skills/SkillLevelUpPanel.java
  30. 216 0
      src/main/lombok/org/leumasjaffe/charsheet/view/skills/WildcardSkillLevelUpLine.java
  31. 5 3
      src/main/lombok/org/leumasjaffe/charsheet/view/summary/InitiativeLine.java
  32. 32 0
      src/main/lombok/org/leumasjaffe/collections/Tree.java

+ 5 - 5
resources/classes/Bard.json

@@ -7,11 +7,11 @@
   "will":"GOOD",
   "features":[
     [
-      {"@c": ".impl.Simple", "name": "Bardic Music"},
-      {"@c": ".impl.Simple", "name": "Bardic Knowledge"},
-      {"@c": ".impl.Simple", "name": "Countersong"},
-      {"@c": ".impl.Simple", "name": "Fascinate"},
-      {"@c": ".impl.Simple", "name": "Inspire Courage +1"}
+      {"@c":".impl.Simple", "name":"Bardic Music"},
+      {"@c":".impl.Simple", "name":"Bardic Knowledge"},
+      {"@c":".impl.Simple", "name":"Countersong"},
+      {"@c":".impl.Simple", "name":"Fascinate"},
+      {"@c":".impl.Simple", "name":"Inspire Courage +1"}
     ],
     []
   ],

+ 7 - 1
resources/classes/Cleric.json

@@ -8,8 +8,14 @@
   "features":[
     [
       {
-        "@c": ".impl.Simple",
+        "@c":".impl.Simple",
         "name":"Turn or Rebuke Undead"
+      },
+      {
+        "@c":".impl.DomainFeature$Chooser",
+        "name":"Domain",
+        "times":2,
+        "choices":["Animal","Earth","Plant"]
       }
     ]
   ],

+ 18 - 0
resources/spells/domain/animal.json

@@ -0,0 +1,18 @@
+{
+  "name":"Domain::Animal",
+  "powers":[
+    {"@c":".impl.Simple","name":"Cast Speak with Animals 1/day"},
+    {"@c":".impl.Simple","name":"Knowledge (nature)"}
+  ],
+  "spells":[
+    "Calm Animals",
+    "Hold Animal",
+    "Dominate Animal",
+    "Sumon Nature's Ally IV",
+    "Commune with Nature",
+    "Antilife Shell",
+    "Animal Shapes",
+    "Summon Nature's Ally VIII",
+    "Shapechange"
+  ]
+}

+ 15 - 0
resources/spells/domain/earth.json

@@ -0,0 +1,15 @@
+{
+  "name":"Domain::Earth",
+  "powers":[{"@c":".impl.Simple","name":"Turn air creatures"}],
+  "spells":[
+    "Magic Stone",
+    "Soften Earth and Stone",
+    "Stone Shape",
+    "Spike Stones",
+    "Wall of Stone",
+    "Stoneskin",
+    "Earthquake",
+    "Iron Body",
+    "Elemental Swarm"
+  ]
+}

+ 4 - 1
resources/spells/domain/plant.json

@@ -1,6 +1,9 @@
 {
   "name":"Domain::Plant",
-  "powers":[],
+  "powers":[
+    {"@c":".impl.Simple","name":"Rebuke plants"},
+    {"@c":".impl.Simple","name":"Knowledge (nature)"}
+  ],
   "spells":[
     "Entangle",
     "Barkskin",

+ 1 - 0
src/main/lombok/org/leumasjaffe/charsheet/config/Constants.java

@@ -9,6 +9,7 @@ public final class Constants {
 	public String PREVIOUS_LOADOUT = "$$PREVIOUS";
 	public String NO_FLAT_FOOTED = "Keeps Dexterity When Flat-footed";
 	public String INITIATIVE = "Character::Initiative";
+	public String GAINSKILL = "Character::ClassSkill";
 	
 	public String K_DISTANCE = "Distance Measurement Unit";
 	public enum DistanceMeasurement {

+ 30 - 8
src/main/lombok/org/leumasjaffe/charsheet/model/DDCharacterClass.java

@@ -1,11 +1,13 @@
 package org.leumasjaffe.charsheet.model;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
+import org.leumasjaffe.charsheet.config.Constants;
 import org.leumasjaffe.charsheet.model.features.DDProperty;
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
@@ -76,12 +78,13 @@ public class DDCharacterClass extends Observable.Instance implements Comparable<
 			return Stream.concat(Stream.of(main), 
 					secondary.map(Stream::of).orElse(Stream.empty())).toArray(DDSpellbook[]::new);
 		}
-		
+				
 		DDSpellbook main;
 		Optional<DDSpellbook> secondary = Optional.empty();
 	}
 	
 	Optional<DDSpellbookWrapper> spellBook;
+	@Getter(AccessLevel.PRIVATE) List<DDProperty> features = new ArrayList<>();
 	
 	public DDCharacterClass(String name) {
 		this.level = new IntValue(0);
@@ -114,7 +117,9 @@ public class DDCharacterClass extends Observable.Instance implements Comparable<
 	}
 	
 	public boolean isClassSkill(final String skill) {
-		return name.base.isClassSkill(skill);
+		return name.base.isClassSkill(skill) ||
+				DDClass.isClassSkill(skill, getFeatureBonuses(Constants.GAINSKILL).stream()
+						.map(DDProperty::getName).collect(Collectors.toSet()));
 	}
 
 	@JsonIgnore public DDClass getProto() {
@@ -125,7 +130,13 @@ public class DDCharacterClass extends Observable.Instance implements Comparable<
 		return getHighestSpellLevel(getLevel().value());
 	}
 	
-	public int getHighestSpellLevel(int level) {
+	@JsonIgnore public Set<String> getClassSkills() {
+		return Stream.concat(getProto().getSkills().stream(),
+				getFeatureBonuses(Constants.GAINSKILL).stream().map(DDProperty::getName))
+				.collect(Collectors.toSet());
+	}
+	
+	@JsonIgnore public int getHighestSpellLevel(int level) {
 		// TODO: Bonus levels to spellsKnown/spellsPerDay?
 		// TODO: Bonus spellsPerDay for high ability scores
 		if (level == 0) { return -1; }
@@ -141,9 +152,20 @@ public class DDCharacterClass extends Observable.Instance implements Comparable<
 	}
 
 	public List<DDProperty> getFeatureBonuses(Object appliesScope) {
-		return IntStream.rangeClosed(1, level.value())
-				.mapToObj(level -> name.base.getFeatures(level).stream())
-				.reduce(Stream.empty(), (l, r) -> Stream.concat(l, r))
-				.filter(p -> p.appliesTo(appliesScope)).collect(Collectors.toList());
+		return this.features.stream().filter(p -> p.appliesTo(appliesScope)).collect(Collectors.toList());
+	}
+	
+	@SuppressWarnings("unused")
+	private void setFeatures(List<DDProperty> feats) {
+		this.features.clear();
+		this.features.addAll(feats);
+	}
+
+	public void addFeature(DDProperty ddProperty) {
+		this.features.add(ddProperty);
+	}
+
+	public void removeFeature(DDProperty ddProperty) {
+		this.features.remove(this.features.lastIndexOf(ddProperty));
 	}
 }

+ 8 - 4
src/main/lombok/org/leumasjaffe/charsheet/model/DDClass.java

@@ -11,7 +11,7 @@ import java.util.Optional;
 import java.util.Set;
 
 import org.leumasjaffe.charsheet.model.DDCharacterClass.DDSpellbookWrapper;
-import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpellList;
 import org.leumasjaffe.charsheet.model.magic.DDSpellList.SpellList;
@@ -48,7 +48,7 @@ public class DDClass {
 	@NonNull SaveQuality ref;
 	@NonNull SaveQuality will;
 	
-	@Getter(AccessLevel.NONE) @NonNull List<List<DDProperty>> features;
+	@Getter(AccessLevel.NONE) @NonNull List<List<DDPropertyChooser>> features;
 	
 	@NonNull Set<String> skills;
 	
@@ -70,15 +70,19 @@ public class DDClass {
 		List<SpellList> list = spells.get().getSpellList();
 		return list.size() <= level ? Collections.emptySet() : list.get( level ).getSpellList();
 	}
-	
+
 	public boolean isClassSkill(final String skillName) {
+		return isClassSkill(skillName, skills);
+	}
+
+	public static boolean isClassSkill(final String skillName, Set<String> skills) {
 		if (skillName.contains("(")) {
 			return skills.contains(skillName) || skills.contains(skillName.replaceFirst("\\(.*\\)", "(*)"));
 		}
 		return skills.contains(skillName);
 	}
 
-	public List<DDProperty> getFeatures(int level) {
+	public List<DDPropertyChooser> getFeatures(int level) {
 		return features.size() < level ? Collections.emptyList() :
 			Collections.unmodifiableList(features.get(level-1));
 	}

+ 3 - 4
src/main/lombok/org/leumasjaffe/charsheet/model/features/DDProperty.java

@@ -1,6 +1,6 @@
 package org.leumasjaffe.charsheet.model.features;
 
-import java.util.Map;
+import org.leumasjaffe.collections.Tree;
 
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
@@ -12,7 +12,6 @@ public interface DDProperty {
 	}
 	String getName();
 	String getDescription();
-	boolean appliesTo(Object key);
-	@Deprecated <T> T value();
-	void accumulate(Map<String, Object> props);
+	boolean appliesTo(final Object key);
+	void accumulate(final Tree<String, GroupedBonus> props, final Object... data);
 }

+ 45 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/features/DDPropertyChooser.java

@@ -0,0 +1,45 @@
+package org.leumasjaffe.charsheet.model.features;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@JsonTypeInfo(use=Id.MINIMAL_CLASS)
+public interface DDPropertyChooser {
+	default int getTimes() { return 1; }
+	String getHeader();
+	default List<String> getChoices() { return Collections.emptyList(); }
+	public DDProperty get(int selectedIndex);
+	default boolean applySideEffects(DDCharacter to, DDCharacterClass toClass, int selectedIndex) {
+		toClass.addFeature(get(selectedIndex));
+		return true;
+	}
+	default void undoSideEffects(DDCharacter to, DDCharacterClass toClass, int selectedIndex) {
+		toClass.removeFeature(get(selectedIndex));
+	}
+	
+	@RequiredArgsConstructor
+	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+	public static class State {
+		DDCharacter ddCharacter;
+		DDCharacterClass ddCharacterClass;
+		DDPropertyChooser chooser;
+		@NonFinal boolean hasApplied = false;
+		@NonFinal int previousIndex = -1;
+		
+		public void apply(int selectedIndex) {
+			if (hasApplied) { chooser.undoSideEffects(ddCharacter, ddCharacterClass, previousIndex); }
+			hasApplied = chooser.applySideEffects(ddCharacter, ddCharacterClass, previousIndex = selectedIndex);
+		}		
+	}
+}

+ 58 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/features/GroupedBonus.java

@@ -0,0 +1,58 @@
+package org.leumasjaffe.charsheet.model.features;
+
+import java.util.EnumMap;
+
+import org.leumasjaffe.charsheet.model.features.DDProperty.Group;
+
+public class GroupedBonus extends Number {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 6846423355848966647L;
+	EnumMap<Group, Number> data = new EnumMap<>(Group.class);
+	
+	public void accumulate(Group grp, Integer i) {
+		data.putIfAbsent(grp, 0);
+		data.compute(grp, (g, n) -> g == Group.NONE ? n.intValue() + i : 
+			Math.max(n.intValue(), i));
+	}
+	
+	public void accumulate(Group grp, Long i) {
+		data.putIfAbsent(grp, 0);
+		data.compute(grp, (g, n) -> g == Group.NONE ? n.longValue() + i : 
+			Math.max(n.longValue(), i));
+	}
+	
+	public void accumulate(Group grp, Float i) {
+		data.putIfAbsent(grp, 0);
+		data.compute(grp, (g, n) -> g == Group.NONE ? n.floatValue() + i : 
+			Math.max(n.floatValue(), i));
+	}
+	
+	public void accumulate(Group grp, Double i) {
+		data.putIfAbsent(grp, 0);
+		data.compute(grp, (g, n) -> g == Group.NONE ? n.doubleValue() + i : 
+			Math.max(n.doubleValue(), i));
+	}
+	
+	@Override
+	public int intValue() {
+		return data.values().stream().mapToInt(Number::intValue).sum();
+	}
+
+	@Override
+	public long longValue() {
+		return data.values().stream().mapToLong(Number::longValue).sum();
+	}
+
+	@Override
+	public float floatValue() {
+		return (float) data.values().stream().mapToDouble(Number::floatValue).sum();
+	}
+
+	@Override
+	public double doubleValue() {
+		return data.values().stream().mapToDouble(Number::doubleValue).sum();
+	}
+
+}

+ 56 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/DomainFeature.java

@@ -0,0 +1,56 @@
+package org.leumasjaffe.charsheet.model.features.impl;
+
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
+import org.leumasjaffe.charsheet.model.magic.impl.Domain;
+import org.leumasjaffe.collections.Tree;
+import org.leumasjaffe.observer.ObserverDispatch;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Delegate;
+
+@AllArgsConstructor @Data
+public class DomainFeature implements DDProperty {
+	private static interface IFaceHelper {
+		void setName(String name);
+		void setDescription(String desc);
+	}
+	@Getter @Setter
+	public static class Chooser implements DDPropertyChooser {
+		@Delegate(types=IFaceHelper.class) DomainFeature impl = new DomainFeature("", "");
+		List<String> choices;
+		int times;
+		
+		@Override public String getHeader() { return impl.getName(); }
+		@Override public DDProperty get(int idx) {
+			return new DomainFeature(impl.getName() + " " + getChoice(idx), impl.getDescription());
+		}
+		@Override public boolean applySideEffects(DDCharacter to, DDCharacterClass toClass, int idx) {
+			boolean val = getDomain(toClass).addDomain(getChoice(idx));
+			ObserverDispatch.notifySubscribers(getDomain(toClass));
+			return val;
+		}
+		@Override public void undoSideEffects(DDCharacter to, DDCharacterClass toClass, int idx) {
+			getDomain(toClass).removeDomain(getChoice(idx));
+		}
+		
+		private Domain getDomain(DDCharacterClass to) {
+			return Domain.class.cast(to.getSpellBook().get().getSecondary().get());
+		}
+		
+		private String getChoice(int idx) {
+			return choices.get(idx);
+		}
+	}
+	String name, description;
+	@Override public boolean appliesTo(Object key) { return false; }
+	@Override public void accumulate(Tree<String, GroupedBonus> props, Object... data) {}
+}

+ 14 - 12
src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Flat.java

@@ -1,15 +1,17 @@
 package org.leumasjaffe.charsheet.model.features.impl;
 
-import java.util.Map;
-
 import org.leumasjaffe.charsheet.model.features.DDFeaturePredicate;
 import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
+import org.leumasjaffe.collections.Tree;
 
 import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
 import lombok.Getter;
 
-@AllArgsConstructor
-public class Flat implements DDProperty {
+@AllArgsConstructor @EqualsAndHashCode
+public class Flat implements DDProperty, DDPropertyChooser {
 	@Getter String name, description;
 	DDFeaturePredicate applies;
 	Group group;
@@ -20,14 +22,14 @@ public class Flat implements DDProperty {
 		return applies.accepts(key);
 	}
 
-	@Override @SuppressWarnings("unchecked")
-	public Object value() {
-		return this.value;
-	}
-
 	@Override
-	public void accumulate(Map<String, Object> props) {
-		// TODO: use groups
-		props.compute("value", (k, old) -> old == null ? value : value + (Integer) old);
+	public void accumulate(Tree<String, GroupedBonus> props, Object... data) {
+		props.putIfAbsent(new GroupedBonus());
+		props.get().accumulate(group, value);
+	}
+	
+	@Override public String getHeader() { return getName(); }
+	@Override public DDProperty get(int selectedIndex) { 
+		return new Flat(name, description, applies, group, value);
 	}
 }

+ 25 - 17
src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/PerSpellLevel.java

@@ -1,16 +1,19 @@
 package org.leumasjaffe.charsheet.model.features.impl;
 
-import java.util.HashMap;
-import java.util.Map;
-
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
 import org.leumasjaffe.charsheet.model.features.DDFeaturePredicate;
 import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.collections.Tree;
 
 import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
 import lombok.Getter;
 
-@AllArgsConstructor
-public class PerSpellLevel implements DDProperty {
+@AllArgsConstructor @EqualsAndHashCode
+public class PerSpellLevel implements DDProperty, DDPropertyChooser {
 	@Getter String name, description;
 	DDFeaturePredicate applies;
 	Group group;
@@ -22,18 +25,23 @@ public class PerSpellLevel implements DDProperty {
 		return applies.accepts(key);
 	}
 
-	@Override @SuppressWarnings("unchecked")
-	public Object value() {
-		return this.value; // TODO consume spell level information
+	@Override
+	public void accumulate(Tree<String, GroupedBonus> props, Object... data) {
+		final DDCharacterClass chara = DDCharacterClass.class.cast(data[0]);
+		final DDSpell spell = DDSpell.class.cast(data[1]);
+		accumulateImpl(props, chara, spell);
 	}
-
-	@Override @SuppressWarnings("unchecked")
-	public void accumulate(Map<String, Object> props) {
-		// TODO: use groups
-		// TODO: allow multiple things
-		// TODO: consume spell level information
-		props.putIfAbsent("effect", new HashMap<>());
-		((Map<String, Integer>) props.get("effect")).compute("value", 
-				(k, old) -> old == null ? value : value + (Integer) old);
+	
+	private void accumulateImpl(Tree<String, GroupedBonus> props, 
+			DDCharacterClass chara, DDSpell spell) {
+		final int level = spell.getClassLevel(chara.getName()).level;
+		// TODO: allow multiple things?
+		props.get("effect").putIfAbsent(new GroupedBonus());
+		props.get("effect").get().accumulate(group, value * level);
+	}
+	
+	@Override public String getHeader() { return getName(); }
+	@Override public DDProperty get(int selectedIndex) { 
+		return new PerSpellLevel(name, description, applies, group, type, value);
 	}
 }

+ 13 - 9
src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Simple.java

@@ -1,17 +1,21 @@
 package org.leumasjaffe.charsheet.model.features.impl;
 
-import java.util.Map;
-
 import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
+import org.leumasjaffe.collections.Tree;
 
 import lombok.AllArgsConstructor;
-import lombok.Getter;
+import lombok.Data;
 
-@AllArgsConstructor @Getter
-public class Simple implements DDProperty {
-	String name;
-	String description;
+@AllArgsConstructor @Data
+public class Simple implements DDProperty, DDPropertyChooser {
+	String name, description;
 	@Override public boolean appliesTo(Object key) { return false; }
-	@Override public <T> T value() { return null; }
-	@Override public void accumulate(Map<String, Object> props) {}
+	@Override public void accumulate(Tree<String, GroupedBonus> props, Object... data) {}
+	
+	@Override public String getHeader() { return getName(); }
+	@Override public DDProperty get(int selectedIndex) { 
+		return new Simple(name, description);
+	}
 }

+ 57 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/features/impl/Skill.java

@@ -0,0 +1,57 @@
+package org.leumasjaffe.charsheet.model.features.impl;
+
+import org.leumasjaffe.charsheet.config.Constants;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.features.DDProperty;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
+import org.leumasjaffe.collections.Tree;
+import org.leumasjaffe.observer.ObserverDispatch;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@AllArgsConstructor @Data
+public class Skill implements DDProperty, DDPropertyChooser {
+	String name;
+	
+	@Override
+	public String getDescription() {
+		return "Gain '" + name + "' as a class Skill";
+	}
+	
+	@Override
+	public boolean appliesTo(Object key) {
+		return Constants.GAINSKILL.equals(key);
+	}
+
+	@Override
+	public void accumulate(Tree<String, GroupedBonus> props, Object... data) {}
+	
+	@Override
+	public String getHeader() { return name; }
+
+	@Override
+	public DDProperty get(int selectedIndex) {
+		return new Skill(name);
+	}
+	
+	@Override
+	public boolean applySideEffects(DDCharacter to, DDCharacterClass toClass, int selectedIndex) {
+		toClass.addFeature(get(selectedIndex));
+		doNotify(to);
+		return true;
+	}
+	
+	@Override
+	public void undoSideEffects(DDCharacter to, DDCharacterClass toClass, int selectedIndex) {
+		toClass.removeFeature(get(selectedIndex));
+		doNotify(to);
+	}
+
+	private void doNotify(DDCharacter to) {
+		ObserverDispatch.notifySubscribers(to.getSkills());
+		ObserverDispatch.notifySubscribers(to.getSkills().getSkill(getName()));
+	}
+}

+ 2 - 4
src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Effect.java

@@ -1,7 +1,5 @@
 package org.leumasjaffe.charsheet.model.magic.dimension;
 
-import java.util.Map;
-
 import org.leumasjaffe.format.Named;
 import org.leumasjaffe.format.StringHelper;
 
@@ -21,8 +19,8 @@ public class Effect {
 	int count, per, beyond;
 	@NonFinal @Setter(AccessLevel.PRIVATE) @Getter(AccessLevel.PRIVATE) int step = 1, upto = Integer.MAX_VALUE;
 	
-	public String getResolved(int level, Map<String, Integer> map) {
-		final int result = map.getOrDefault("value", 0) + count + per * ((Math.min(level, upto)-beyond) / step);
+	public String getResolved(int level, Number val) {
+		final int result = val.intValue() + count + per * ((Math.min(level, upto)-beyond) / step);
 		return resolved == null ? toString() :
 			StringHelper.format(resolved, new Named("atlevel", result));
 	}

+ 11 - 2
src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Domain.java

@@ -10,6 +10,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.features.DDProperty;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
@@ -34,9 +35,9 @@ public class Domain extends Prepared implements DDSpellbook.Secondary {
 			return mapper.readValue(new File("resources/spells/domain/" + name.toLowerCase() + ".json"), 
 					new TypeReference<SpellBookImpl>() {});
 		}
-		@JsonValue private String getImplName() { return name.replaceAll(".*::", ""); }
+		@JsonValue public String getImplName() { return name.replaceAll(".*::", ""); }
 		String name;
-		List<Object> powers;
+		List<DDProperty> powers;
 		List<DDSpell> spells;
 	}
 	
@@ -95,4 +96,12 @@ public class Domain extends Prepared implements DDSpellbook.Secondary {
 		if (spellsPreparedPreviously.size() < level || level == 0) return Collections.emptyList();
 		return Collections.singletonList(spellsPreparedPreviously.get(level-1));
 	}
+
+	public boolean addDomain(String forSelection) {
+		return this.domains.add(SpellBookImpl.create(forSelection));
+	}
+
+	public void removeDomain(String forSelection) {
+		this.domains.remove(SpellBookImpl.create(forSelection));
+	}
 }

+ 8 - 3
src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkill.java

@@ -1,19 +1,20 @@
 package org.leumasjaffe.charsheet.model.skill;
 
 import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.observer.Observable;
 
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.experimental.Delegate;
 import lombok.Setter;
 import lombok.experimental.FieldDefaults;
 
-@AllArgsConstructor
-@Data
+@AllArgsConstructor @Data @EqualsAndHashCode(callSuper=false)
 @FieldDefaults(level=AccessLevel.PRIVATE)
-public class DDSkill {
+public class DDSkill extends Observable.Instance {
 	final @Getter(AccessLevel.NONE) @Delegate DDSkillPrototype name;
 
 //	boolean isClassSkill = false;
@@ -22,6 +23,10 @@ public class DDSkill {
 	// Unless you gain it as a class skill later, in which case it might be in-between
 	@Setter(value=AccessLevel.PRIVATE) int pointsSpent = 0;
 	
+	public DDSkill(String name) {
+		this(new DDSkillPrototype(name));
+	}
+	
 	public DDSkill(DDSkillPrototype proto) {
 		this.name = proto;
 	}

+ 21 - 12
src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkillPrototype.java

@@ -6,7 +6,6 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Optional;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -18,13 +17,13 @@ import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.experimental.FieldDefaults;
 
-@AllArgsConstructor
-@Data
+@AllArgsConstructor @Data
 @FieldDefaults(level=AccessLevel.PRIVATE)
-public class DDSkillPrototype {
+class DDSkillPrototype {
 	final String name;
 	final boolean requiresTraining;
-	String ability;
+	String ability = "";
+	boolean fromWildcardSkill = false;
 	
 	private static final Map<String, DDSkillPrototype> prototypes;
 	
@@ -43,23 +42,33 @@ public class DDSkillPrototype {
 	}
 	
 	public DDSkillPrototype(String name) {
-		DDSkillPrototype base = getPrototype(name).get();
+		DDSkillPrototype base = getPrototype(name);
 		this.name = name;
 		this.requiresTraining = base.requiresTraining;
 		this.ability = base.ability;
+		this.fromWildcardSkill = base.fromWildcardSkill;
 	}
 	
-	public static Optional<DDSkillPrototype> getPrototype(String name) {
+	public boolean isWildcardSkill() {
+		return name.contains("(*)");
+	}
+	
+	public static DDSkillPrototype getPrototype(String name) {
 		if (name.contains("(")) {
 			DDSkillPrototype proto = prototypes.get(name.replaceFirst("\\(.*\\)", "(*)"));
-			if ( proto == null ) return Optional.empty();
-			return Optional.of(new DDSkillPrototype(name, proto.requiresTraining, proto.ability));
-		} else {
-			return Optional.ofNullable(prototypes.get(name));
+			if (proto != null) { return new DDSkillPrototype(name, proto.requiresTraining, proto.ability, true); }
+		} 
+		if (prototypes.containsKey(name)) {
+			return prototypes.get(name);
 		}
+		throw new IllegalStateException("Unknown skill '" + name + "'");
+	}
+	
+	public static Stream<DDSkillPrototype> all() {
+		return prototypes.values().stream();
 	}
 
 	public static Stream<DDSkillPrototype> untrained() {
 		return prototypes.values().stream().filter(p -> !p.requiresTraining && !p.getName().contains("(*)"));
-	}
+	}	
 }

+ 17 - 2
src/main/lombok/org/leumasjaffe/charsheet/model/skill/DDSkills.java

@@ -6,6 +6,8 @@ import java.util.Map;
 import java.util.TreeMap;
 import java.util.stream.Collectors;
 
+import org.leumasjaffe.observer.Observable;
+
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonValue;
 
@@ -13,12 +15,12 @@ import lombok.AccessLevel;
 import lombok.experimental.FieldDefaults;
 
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class DDSkills {
+public class DDSkills extends Observable.Instance {
 	Map<String, DDSkill> skills = new TreeMap<>();
 	
 	@JsonCreator
 	public DDSkills(Collection<DDSkill> in) {
-		skills.putAll(DDSkillPrototype.untrained().collect(Collectors.toMap(t -> t.getName(), t -> new DDSkill(t))));
+		skills.putAll(DDSkillPrototype.all().collect(Collectors.toMap(t -> t.getName(), t -> new DDSkill(t))));
 		skills.putAll(in.stream().collect(Collectors.toMap(t -> t.getName(), t -> t)));
 	}
 	
@@ -30,4 +32,17 @@ public class DDSkills {
 	public Collection<DDSkill> getSkills() {
 		return Collections.unmodifiableCollection(skills.values());
 	}
+
+	public DDSkill getSkill(String name) {
+		skills.computeIfAbsent(name, DDSkill::new);
+		return skills.get(name);
+	}
+	
+	public void removeSkill(DDSkill skill) {
+		final DDSkill removed = skills.get(skill.getName());
+		if (removed != null && !skill.equals(removed)) {
+			throw new IllegalArgumentException("Attempting to remove a skill (" + skill.getName() + ") not in the object");
+		}
+		skills.remove(skill.getName());
+	}
 }

+ 51 - 24
src/main/lombok/org/leumasjaffe/charsheet/view/SkillTab.java

@@ -6,28 +6,33 @@ import javax.swing.JScrollPane;
 import java.awt.GridBagConstraints;
 import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.skill.DDSkill;
 import org.leumasjaffe.charsheet.model.skill.DDSkills;
 import org.leumasjaffe.charsheet.view.skills.SkillLine;
+import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 
 import lombok.AccessLevel;
 import lombok.experimental.FieldDefaults;
 import java.awt.Dimension;
 import java.awt.Insets;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
 import javax.swing.JLabel;
 import javax.swing.JTextField;
 import java.awt.Component;
 import javax.swing.Box;
 
+@SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class SkillTab extends JPanel {
-	/**
-	 * 
-	 */
-	private static final long serialVersionUID = 1L;
-	JPanel skillPanel;
-	JTextField txtTotalSkillPoints;
-	JTextField txtClassSkills;
-	JTextField txtCrossClass;
+	
+	ObservableListener<JTextField, DDSkills> totalListener;
+	ObservableListener<JTextField, DDCharacter> classSkillListener;
+	ObservableListener<JTextField, DDCharacter> crossSkillListener;
+	ObservableListener<JPanel, DDCharacter> skillListener;
 
 	public SkillTab() {
 		setPreferredSize(new Dimension(600, 300));
@@ -60,7 +65,7 @@ public class SkillTab extends JPanel {
 		gbc_lblTotalSkillPoints.gridy = 0;
 		panel.add(lblTotalSkillPoints, gbc_lblTotalSkillPoints);
 		
-		txtTotalSkillPoints = new JTextField();
+		JTextField txtTotalSkillPoints = new JTextField();
 		txtTotalSkillPoints.setEditable(false);
 		GridBagConstraints gbc_txtTotalSkillPoints = new GridBagConstraints();
 		gbc_txtTotalSkillPoints.insets = new Insets(0, 0, 0, 5);
@@ -85,7 +90,7 @@ public class SkillTab extends JPanel {
 		gbc_lblMaxRanksClasscross.gridy = 0;
 		panel.add(lblMaxRanksClasscross, gbc_lblMaxRanksClasscross);
 		
-		txtClassSkills = new JTextField();
+		JTextField txtClassSkills = new JTextField();
 		txtClassSkills.setEditable(false);
 		GridBagConstraints gbc_txtClassSkills = new GridBagConstraints();
 		gbc_txtClassSkills.insets = new Insets(0, 0, 0, 5);
@@ -103,7 +108,7 @@ public class SkillTab extends JPanel {
 		gbc_label.gridy = 0;
 		panel.add(label, gbc_label);
 		
-		txtCrossClass = new JTextField();
+		JTextField txtCrossClass = new JTextField();
 		txtCrossClass.setEditable(false);
 		GridBagConstraints gbc_txtCrossClass = new GridBagConstraints();
 		gbc_txtCrossClass.fill = GridBagConstraints.HORIZONTAL;
@@ -119,24 +124,46 @@ public class SkillTab extends JPanel {
 		gbc_scrollPane.gridy = 1;
 		add(scrollPane, gbc_scrollPane);
 		
-		skillPanel = new JPanel();
+		JPanel skillPanel = new JPanel();
 		scrollPane.setViewportView(skillPanel);
 		skillPanel.setLayout(new VerticalLayout());
+		
+		totalListener = new ObservableListener<>(txtTotalSkillPoints, (c, v) -> {
+			final int totalPoints = v.getSkills().stream().mapToInt(DDSkill::getPointsSpent).sum();
+			c.setText(Integer.toString(totalPoints));
+		});
+		classSkillListener = new ObservableListener<>(txtClassSkills, (c, v) -> {
+			c.setText(Integer.toString(v.getLevel() + 3));
+		});
+		crossSkillListener = new ObservableListener<>(txtCrossClass, (c, v) -> {
+			c.setText(Integer.toString((v.getLevel() + 3)/2));
+		});
+		Map<String, SkillLine> lines = new TreeMap<>();
+		skillListener = new ObservableListener<>(skillPanel, (c, v) -> {
+			Stream<DDSkill> st = v.getSkills().getSkills().stream().filter(
+					sk -> sk.getPointsSpent() > 0 || !sk.isRequiresTraining());
+			st.forEach(sk -> lines.computeIfAbsent(sk.getName(), k -> new SkillLine(v, sk)));
+			skillPanel.removeAll();
+			lines.values().forEach(skillPanel::add);
+			revalidate();
+			repaint();
+		});
 	}
 	
 	public void setModel(final DDCharacter model) {
-		// TODO (sjaffe): Observables?
-		skillPanel.removeAll();
-		int[] totalPoints = {0};
-		final DDSkills skills = model.getSkills();
-		skills.getSkills().stream().forEach( skill -> {
-			skillPanel.add(new SkillLine(model, skill));
-			totalPoints[0] += skill.getPointsSpent();
-		});
-		txtTotalSkillPoints.setText(Integer.toString(totalPoints[0]));
-		int classSkill = model.getLevel() + 3;
-		txtClassSkills.setText(Integer.toString(classSkill));
-		txtCrossClass.setText(Integer.toString(classSkill / 2));
+		DDSkills skills = model.getSkills();
+		classSkillListener.setObserved(model);
+		crossSkillListener.setObserved(model);
+		totalListener.setObserved(skills);
+		skillListener.setObserved(model);
 	}
 	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(classSkillListener);
+		ObserverDispatch.unsubscribeAll(crossSkillListener);
+		ObserverDispatch.unsubscribeAll(totalListener);
+		ObserverDispatch.unsubscribeAll(skillListener);
+	}	
 }

+ 3 - 2
src/main/lombok/org/leumasjaffe/charsheet/view/level/LU_FeaturesPanel.java

@@ -1,6 +1,5 @@
 package org.leumasjaffe.charsheet.view.level;
 
-import javax.swing.JLabel;
 import javax.swing.JPanel;
 
 import org.jdesktop.swingx.VerticalLayout;
@@ -81,7 +80,9 @@ class LU_FeaturesPanel extends JPanel {
 		gbc_features.gridy = 1;
 		panel_1.add(features, gbc_features);
 		info.ddClass.getProto().getFeatures(info.toLevel).forEach(prop -> {
-			features.add(new JLabel(prop.getName()));
+			for (int i = 0; i < prop.getTimes(); ++i) {
+				features.add(new PropertyChoicePanel(info.ddCharacter, info.ddClass, prop, i));
+			}
 		});
 		
 		abilPanel = new LU_AbilityPanel(info, gate.handle(ABIL_INDEX));

+ 55 - 0
src/main/lombok/org/leumasjaffe/charsheet/view/level/PropertyChoicePanel.java

@@ -0,0 +1,55 @@
+package org.leumasjaffe.charsheet.view.level;
+
+import javax.swing.JPanel;
+
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.features.DDPropertyChooser;
+import java.awt.GridBagLayout;
+import javax.swing.JLabel;
+import java.awt.GridBagConstraints;
+import javax.swing.JComboBox;
+import java.awt.Insets;
+import java.awt.event.ItemEvent;
+
+@SuppressWarnings("serial")
+public class PropertyChoicePanel extends JPanel {
+
+	public PropertyChoicePanel(DDCharacter chara, DDCharacterClass dchara, DDPropertyChooser prop, int idx) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblName = new JLabel(prop.getHeader());
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.insets = new Insets(0, 0, 0, 5);
+		gbc_lblName.anchor = GridBagConstraints.EAST;
+		gbc_lblName.gridx = 0;
+		gbc_lblName.gridy = 0;
+		add(lblName, gbc_lblName);
+		
+		if (!prop.getChoices().isEmpty()) {
+			DDPropertyChooser.State state = new DDPropertyChooser.State(chara, dchara, prop);
+
+			JComboBox<String> comboBox = new JComboBox<>(prop.getChoices().toArray(new String[0]));
+			GridBagConstraints gbc_comboBox = new GridBagConstraints();
+			gbc_comboBox.fill = GridBagConstraints.HORIZONTAL;
+			gbc_comboBox.gridx = 1;
+			gbc_comboBox.gridy = 0;
+			add(comboBox, gbc_comboBox);
+			
+			comboBox.setSelectedIndex(idx);
+			state.apply(idx);
+			comboBox.addItemListener(e -> {
+				if (e.getStateChange() == ItemEvent.SELECTED) {
+					state.apply(comboBox.getSelectedIndex());
+				}
+			});
+			
+		}
+	}
+
+}

+ 4 - 4
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SelectSpellsPanel.java

@@ -16,7 +16,7 @@ import org.leumasjaffe.charsheet.model.observable.BoolGate;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
 import org.leumasjaffe.event.SelectTableRowPopupMenuListener;
 import org.leumasjaffe.format.StringHelper;
-import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.IndirectObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 
 import lombok.AccessLevel;
@@ -86,7 +86,7 @@ public class SelectSpellsPanel extends JPanel {
 	BoolGate.Handle gate;
 	JTable tablePrepared, tableKnown;
 	
-	ObservableListener<JTable, SpellPicker> listener;
+	IndirectObservableListener<JTable, SpellPicker> listener;
 	
 	public SelectSpellsPanel(SpellPicker pick, BoolGate.Handle gate, int level, 
 			Collection<DDSpell> prepared, IntValue sharedValue) {
@@ -191,12 +191,12 @@ public class SelectSpellsPanel extends JPanel {
 		
 		button_1.addActionListener(e -> insertSpell());
 		
-		listener = new ObservableListener<>(tableKnown, (c, v) -> {
+		listener = new IndirectObservableListener<>(tableKnown, (c, v) -> {
 			known.clear();
 			known.addAll(v.getAvailableSpells(level));
 			this.modelKnown.data = createModel(known);
 		});
-		listener.setObserved(pick);
+		listener.setObserved(pick, pick, pick.getInfo().spellBook);
 	}
 	
 	private void removeSpell() {

+ 6 - 8
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java

@@ -4,9 +4,6 @@ import javax.swing.JPanel;
 import java.awt.GridBagLayout;
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
@@ -15,10 +12,12 @@ import javax.swing.JTextArea;
 
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpell.Component;
 import org.leumasjaffe.charsheet.model.magic.Source;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.collections.Tree;
 
 import javax.swing.JScrollPane;
 import javax.swing.JLabel;
@@ -38,8 +37,8 @@ class SpellInfoPanel extends JPanel {
 
 	public SpellInfoPanel(DDCharacter chara, DDCharacterClass dclass, final DDSpell spell) {
 		final IntValue classLevel = dclass.getLevel();
-		Map<String, Object> props = new HashMap<>();
-		chara.getFeatureBonuses(spell).forEach(p -> p.accumulate(props));
+		Tree<String, GroupedBonus> props = new Tree<>();
+		chara.getFeatureBonuses(spell).forEach(p -> p.accumulate(props, dclass, spell));
 		
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0};
@@ -345,9 +344,8 @@ class SpellInfoPanel extends JPanel {
 		description.setLineWrap(true);
 	}
 
-	@SuppressWarnings("unchecked")
-	private Map<String, Integer> getSpellBonus(String key, Map<String, Object> props) {
-		return (Map<String, Integer>) props.getOrDefault(key, Collections.emptyMap());
+	private Number getSpellBonus(String key, Tree<String, GroupedBonus> props) {
+		return props.get(key).getOrDefault(new GroupedBonus());
 	}
 
 	private <T> String asString(Optional<T> obj, Function<? super T, String> ts) {

+ 246 - 0
src/main/lombok/org/leumasjaffe/charsheet/view/skills/NormalSkillLevelUpLine.java

@@ -0,0 +1,246 @@
+package org.leumasjaffe.charsheet.view.skills;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.model.skill.DDSkill;
+import org.leumasjaffe.charsheet.util.AbilityHelper;
+import org.leumasjaffe.format.StringHelper;
+import org.leumasjaffe.observer.IndirectObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+
+import lombok.AccessLevel;
+import lombok.Value;
+import lombok.experimental.FieldDefaults;
+
+import java.awt.GridBagLayout;
+import javax.swing.JCheckBox;
+import java.awt.GridBagConstraints;
+import javax.swing.JLabel;
+import java.awt.Insets;
+import java.util.Optional;
+import java.util.function.IntFunction;
+import java.awt.Dimension;
+import javax.swing.JTextField;
+import java.awt.Color;
+import javax.swing.border.MatteBorder;
+import javax.swing.SwingConstants;
+import java.awt.Component;
+import javax.swing.Box;
+import javax.swing.JButton;
+import java.awt.Font;
+
+@SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class NormalSkillLevelUpLine extends SkillLevelUpLine {
+	boolean isClassSkill;
+	DDSkill skill;
+	IntValue current;
+	IndirectObservableListener<JTextField, TotalPacket> totalListener;
+	
+	@Value
+	private static final class TotalPacket {
+		Optional<Ability.Scores> ability;
+		DDSkill skill;
+		IntValue points;
+	}
+	
+	public NormalSkillLevelUpLine(final DDCharacter chara, final DDCharacterClass cclass, 
+			final DDSkill skill, IntValue pointsAvaliable) {
+		isClassSkill = cclass.isClassSkill(skill.getName());
+		this.skill = skill;
+		current = new IntValue(0);
+		final int pointsPerRank = isClassSkill ? 1 : 2;
+		final int maxPoints = (chara.getLevel() + 3) - skill.getRanks().value();
+		
+		setBorder(new MatteBorder(0, 0, 1, 0, new Color(0, 0, 0)));
+		setPreferredSize(new Dimension(480, 22));
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JCheckBox checkBoxIsClassSkill = new JCheckBox("");
+		checkBoxIsClassSkill.setToolTipText("Class Skill?");
+		checkBoxIsClassSkill.setSelected(cclass.isClassSkill(skill.getName()));
+		checkBoxIsClassSkill.setEnabled(false);
+		GridBagConstraints gbc_checkBoxIsClassSkill = new GridBagConstraints();
+		gbc_checkBoxIsClassSkill.insets = new Insets(1, 0, 0, 5);
+		gbc_checkBoxIsClassSkill.gridx = 0;
+		gbc_checkBoxIsClassSkill.gridy = 0;
+		add(checkBoxIsClassSkill, gbc_checkBoxIsClassSkill);
+		
+		JLabel lblName = new JLabel(skill.getName());
+		lblName.setToolTipText(skill.getName());
+		lblName.setMaximumSize(new Dimension(150, 14));
+		lblName.setMinimumSize(new Dimension(150, 14));
+		lblName.setPreferredSize(new Dimension(150, 14));
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.fill = GridBagConstraints.HORIZONTAL;
+		gbc_lblName.insets = new Insets(1, 0, 0, 5);
+		gbc_lblName.gridx = 1;
+		gbc_lblName.gridy = 0;
+		add(lblName, gbc_lblName);
+		
+		JLabel lblAbil = new JLabel(skill.getAbility());
+		lblAbil.setMaximumSize(new Dimension(30, 14));
+		lblAbil.setMinimumSize(new Dimension(30, 14));
+		lblAbil.setPreferredSize(new Dimension(30, 14));
+		GridBagConstraints gbc_lblAbil = new GridBagConstraints();
+		gbc_lblAbil.insets = new Insets(1, 0, 0, 5);
+		gbc_lblAbil.anchor = GridBagConstraints.EAST;
+		gbc_lblAbil.gridx = 2;
+		gbc_lblAbil.gridy = 0;
+		add(lblAbil, gbc_lblAbil);
+		
+		JTextField total = new JTextField();
+		total.setToolTipText("Skill Modifier");
+		total.setHorizontalAlignment(SwingConstants.CENTER);
+		total.setEditable(false);
+		total.setMinimumSize(new Dimension(30, 20));
+		total.setMaximumSize(new Dimension(30, 20));
+		total.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_total = new GridBagConstraints();
+		gbc_total.insets = new Insets(1, 0, 0, 5);
+		gbc_total.fill = GridBagConstraints.HORIZONTAL;
+		gbc_total.gridx = 3;
+		gbc_total.gridy = 0;
+		add(total, gbc_total);
+		total.setColumns(10);
+		
+		JLabel label = new JLabel("=");
+		GridBagConstraints gbc_label = new GridBagConstraints();
+		gbc_label.anchor = GridBagConstraints.EAST;
+		gbc_label.insets = new Insets(1, 0, 0, 5);
+		gbc_label.gridx = 4;
+		gbc_label.gridy = 0;
+		add(label, gbc_label);
+		
+		JTextField modifier = new JTextField();
+		modifier.setToolTipText("Ability Modifier");
+		modifier.setHorizontalAlignment(SwingConstants.CENTER);
+		modifier.setEditable(false);
+		modifier.setMinimumSize(new Dimension(30, 20));
+		modifier.setMaximumSize(new Dimension(30, 20));
+		modifier.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_modifier = new GridBagConstraints();
+		gbc_modifier.insets = new Insets(1, 0, 0, 5);
+		gbc_modifier.fill = GridBagConstraints.HORIZONTAL;
+		gbc_modifier.gridx = 5;
+		gbc_modifier.gridy = 0;
+		add(modifier, gbc_modifier);
+		modifier.setColumns(10);
+		
+		JLabel label_1 = new JLabel("+");
+		GridBagConstraints gbc_label_1 = new GridBagConstraints();
+		gbc_label_1.anchor = GridBagConstraints.EAST;
+		gbc_label_1.insets = new Insets(1, 0, 0, 5);
+		gbc_label_1.gridx = 6;
+		gbc_label_1.gridy = 0;
+		add(label_1, gbc_label_1);
+		
+		JTextField ranks = new JTextField(StringHelper.toString(skill.getRanks()));
+		ranks.setToolTipText("Ranks");
+		ranks.setHorizontalAlignment(SwingConstants.CENTER);
+		ranks.setEditable(false);
+		ranks.setMinimumSize(new Dimension(30, 20));
+		ranks.setMaximumSize(new Dimension(30, 20));
+		ranks.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_ranks = new GridBagConstraints();
+		gbc_ranks.insets = new Insets(1, 0, 0, 5);
+		gbc_ranks.fill = GridBagConstraints.HORIZONTAL;
+		gbc_ranks.gridx = 7;
+		gbc_ranks.gridy = 0;
+		add(ranks, gbc_ranks);
+		ranks.setColumns(10);
+		
+		Component horizontalStrut = Box.createHorizontalStrut(10);
+		GridBagConstraints gbc_horizontalStrut = new GridBagConstraints();
+		gbc_horizontalStrut.gridx = 8;
+		gbc_horizontalStrut.gridy = 0;
+		add(horizontalStrut, gbc_horizontalStrut);
+		
+		JButton plus = new JButton("+");
+		plus.setMargin(new Insets(2, 2, 2, 2));
+		plus.setFont(new Font("Tahoma", Font.PLAIN, 8));
+		plus.setPreferredSize(new Dimension(25, 19));
+		plus.setMinimumSize(new Dimension(25, 19));
+		GridBagConstraints gbc_plus = new GridBagConstraints();
+		gbc_plus.insets = new Insets(0, 0, 1, 5);
+		gbc_plus.gridx = 9;
+		gbc_plus.gridy = 0;
+		add(plus, gbc_plus);
+		
+		JButton minus = new JButton("-");
+		minus.setMargin(new Insets(2, 2, 2, 2));
+		minus.setFont(new Font("Tahoma", Font.PLAIN, 8));
+		minus.setMinimumSize(new Dimension(25, 19));
+		minus.setPreferredSize(new Dimension(25, 19));
+		GridBagConstraints gbc_minus = new GridBagConstraints();
+		gbc_minus.insets = new Insets(0, 0, 1, 5);
+		gbc_minus.gridx = 10;
+		gbc_minus.gridy = 0;
+		add(minus, gbc_minus);
+		
+		JTextField points = new JTextField();
+		points.setMinimumSize(new Dimension(30, 20));
+		points.setText("0");
+		points.setEditable(false);
+		GridBagConstraints gbc_points = new GridBagConstraints();
+		gbc_points.insets = new Insets(0, 0, 0, 5);
+		gbc_points.gridx = 11;
+		gbc_points.gridy = 0;
+		add(points, gbc_points);
+		points.setColumns(10);
+		
+		Component horizontalStrut_1 = Box.createHorizontalStrut(5);
+		GridBagConstraints gbc_horizontalStrut_1 = new GridBagConstraints();
+		gbc_horizontalStrut_1.gridx = 12;
+		gbc_horizontalStrut_1.gridy = 0;
+		add(horizontalStrut_1, gbc_horizontalStrut_1);
+
+		IntFunction<Void> lambda = (value) -> {
+			pointsAvaliable.value(pointsAvaliable.value() - (value * pointsPerRank));
+			current.value(current.value() + value);
+			points.setText(Integer.toString(current.value()));
+			ObserverDispatch.notifySubscribers(pointsAvaliable); // TODO (this)?
+			ObserverDispatch.notifySubscribers(current); // TODO (this)?
+			return null;
+		};
+		
+		plus.addActionListener((e) -> {
+			if (pointsAvaliable.value() >= pointsPerRank && current.value() < maxPoints) { lambda.apply(1); }
+		});
+		minus.addActionListener((e) -> {
+			if (current.value() > 0) { lambda.apply(-1); }
+		});
+
+		totalListener = new IndirectObservableListener<>(total, (c, p) -> {
+			final int skillRanks = p.skill.getRanks().value();
+			final int mod = p.ability.map(v -> v.baseModifier()).orElse(0);
+			c.setText(StringHelper.toString(skillRanks + mod + p.points.value()));
+		});
+		final Optional<Ability.Scores> ability = getAbility(chara, skill);
+		ability.ifPresent(v -> modifier.setText(StringHelper.toString(v.baseModifier())));
+		totalListener.setObserved(new TotalPacket(ability, skill, current), current, skill);
+	}
+
+	private Optional<Ability.Scores> getAbility(final DDCharacter chara, final DDSkill skill) {
+		if (skill.getAbility() == null || skill.getAbility().isEmpty()) { return Optional.empty(); }
+		else { return Optional.of(AbilityHelper.get(chara, skill)); }
+	}
+
+	public void applyChange() {
+		skill.spendPoints(current.value(), !isClassSkill);
+		ObserverDispatch.notifySubscribers(skill.getRanks()); // TODO (this)?
+	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(totalListener);
+	}
+}

+ 3 - 246
src/main/lombok/org/leumasjaffe/charsheet/view/skills/SkillLevelUpLine.java

@@ -2,250 +2,7 @@ package org.leumasjaffe.charsheet.view.skills;
 
 import javax.swing.JPanel;
 
-import org.leumasjaffe.charsheet.model.Ability;
-import org.leumasjaffe.charsheet.model.DDCharacter;
-import org.leumasjaffe.charsheet.model.DDCharacterClass;
-import org.leumasjaffe.charsheet.model.observable.IntValue;
-import org.leumasjaffe.charsheet.model.skill.DDSkill;
-import org.leumasjaffe.charsheet.util.AbilityHelper;
-import org.leumasjaffe.format.StringHelper;
-import org.leumasjaffe.observer.IndirectObservableListener;
-import org.leumasjaffe.observer.ObserverDispatch;
-
-import lombok.AccessLevel;
-import lombok.Value;
-import lombok.experimental.FieldDefaults;
-
-import java.awt.GridBagLayout;
-import javax.swing.JCheckBox;
-import java.awt.GridBagConstraints;
-import javax.swing.JLabel;
-import java.awt.Insets;
-import java.util.Optional;
-import java.util.function.IntFunction;
-import java.awt.Dimension;
-import javax.swing.JTextField;
-import java.awt.Color;
-import javax.swing.border.MatteBorder;
-import javax.swing.SwingConstants;
-import java.awt.Component;
-import javax.swing.Box;
-import javax.swing.JButton;
-import java.awt.Font;
-
-@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-class SkillLevelUpLine extends JPanel {
-	/**
-	 * 
-	 */
-	private static final long serialVersionUID = 1L;
-	boolean isClassSkill;
-	DDSkill skill;
-	IntValue current;
-	IndirectObservableListener<JTextField, TotalPacket> totalListener;
-	
-	@Value
-	private static final class TotalPacket {
-		Optional<Ability.Scores> ability;
-		DDSkill skill;
-		IntValue points;
-	}
-	
-	public SkillLevelUpLine(final DDCharacter chara, final DDCharacterClass cclass, final DDSkill skill, IntValue pointsAvaliable) {
-		isClassSkill = cclass.isClassSkill(skill.getName());
-		this.skill = skill;
-		current = new IntValue(0);
-		final int pointsPerRank = isClassSkill ? 1 : 2;
-		final int maxPoints = (chara.getLevel() + 3) / pointsPerRank - skill.getRanks().value();
-		
-		setBorder(new MatteBorder(0, 0, 1, 0, (Color) new Color(0, 0, 0)));
-		setPreferredSize(new Dimension(480, 22));
-		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
-		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
-		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
-		setLayout(gridBagLayout);
-		
-		JCheckBox checkBoxIsClassSkill = new JCheckBox("");
-		checkBoxIsClassSkill.setToolTipText("Class Skill?");
-		checkBoxIsClassSkill.setSelected(cclass.isClassSkill(skill.getName()));
-		checkBoxIsClassSkill.setEnabled(false);
-		GridBagConstraints gbc_checkBoxIsClassSkill = new GridBagConstraints();
-		gbc_checkBoxIsClassSkill.insets = new Insets(1, 0, 0, 5);
-		gbc_checkBoxIsClassSkill.gridx = 0;
-		gbc_checkBoxIsClassSkill.gridy = 0;
-		add(checkBoxIsClassSkill, gbc_checkBoxIsClassSkill);
-		
-		JLabel lblName = new JLabel(skill.getName());
-		lblName.setMaximumSize(new Dimension(150, 14));
-		lblName.setMinimumSize(new Dimension(150, 14));
-		lblName.setPreferredSize(new Dimension(150, 14));
-		GridBagConstraints gbc_lblName = new GridBagConstraints();
-		gbc_lblName.fill = GridBagConstraints.HORIZONTAL;
-		gbc_lblName.insets = new Insets(1, 0, 0, 5);
-		gbc_lblName.gridx = 1;
-		gbc_lblName.gridy = 0;
-		add(lblName, gbc_lblName);
-		
-		JLabel lblAbil = new JLabel(skill.getAbility());
-		lblAbil.setMaximumSize(new Dimension(30, 14));
-		lblAbil.setMinimumSize(new Dimension(30, 14));
-		lblAbil.setPreferredSize(new Dimension(30, 14));
-		GridBagConstraints gbc_lblAbil = new GridBagConstraints();
-		gbc_lblAbil.insets = new Insets(1, 0, 0, 5);
-		gbc_lblAbil.anchor = GridBagConstraints.EAST;
-		gbc_lblAbil.gridx = 2;
-		gbc_lblAbil.gridy = 0;
-		add(lblAbil, gbc_lblAbil);
-		
-		JTextField total = new JTextField();
-		total.setToolTipText("Skill Modifier");
-		total.setHorizontalAlignment(SwingConstants.CENTER);
-		total.setEditable(false);
-		total.setMinimumSize(new Dimension(30, 20));
-		total.setMaximumSize(new Dimension(30, 20));
-		total.setPreferredSize(new Dimension(30, 20));
-		GridBagConstraints gbc_total = new GridBagConstraints();
-		gbc_total.insets = new Insets(1, 0, 0, 5);
-		gbc_total.fill = GridBagConstraints.HORIZONTAL;
-		gbc_total.gridx = 3;
-		gbc_total.gridy = 0;
-		add(total, gbc_total);
-		total.setColumns(10);
-		
-		JLabel label = new JLabel("=");
-		GridBagConstraints gbc_label = new GridBagConstraints();
-		gbc_label.anchor = GridBagConstraints.EAST;
-		gbc_label.insets = new Insets(1, 0, 0, 5);
-		gbc_label.gridx = 4;
-		gbc_label.gridy = 0;
-		add(label, gbc_label);
-		
-		JTextField modifier = new JTextField();
-		modifier.setToolTipText("Ability Modifier");
-		modifier.setHorizontalAlignment(SwingConstants.CENTER);
-		modifier.setEditable(false);
-		modifier.setMinimumSize(new Dimension(30, 20));
-		modifier.setMaximumSize(new Dimension(30, 20));
-		modifier.setPreferredSize(new Dimension(30, 20));
-		GridBagConstraints gbc_modifier = new GridBagConstraints();
-		gbc_modifier.insets = new Insets(1, 0, 0, 5);
-		gbc_modifier.fill = GridBagConstraints.HORIZONTAL;
-		gbc_modifier.gridx = 5;
-		gbc_modifier.gridy = 0;
-		add(modifier, gbc_modifier);
-		modifier.setColumns(10);
-		
-		JLabel label_1 = new JLabel("+");
-		GridBagConstraints gbc_label_1 = new GridBagConstraints();
-		gbc_label_1.anchor = GridBagConstraints.EAST;
-		gbc_label_1.insets = new Insets(1, 0, 0, 5);
-		gbc_label_1.gridx = 6;
-		gbc_label_1.gridy = 0;
-		add(label_1, gbc_label_1);
-		
-		JTextField ranks = new JTextField(StringHelper.toString(skill.getRanks()));
-		ranks.setToolTipText("Ranks");
-		ranks.setHorizontalAlignment(SwingConstants.CENTER);
-		ranks.setEditable(false);
-		ranks.setMinimumSize(new Dimension(30, 20));
-		ranks.setMaximumSize(new Dimension(30, 20));
-		ranks.setPreferredSize(new Dimension(30, 20));
-		GridBagConstraints gbc_ranks = new GridBagConstraints();
-		gbc_ranks.insets = new Insets(1, 0, 0, 5);
-		gbc_ranks.fill = GridBagConstraints.HORIZONTAL;
-		gbc_ranks.gridx = 7;
-		gbc_ranks.gridy = 0;
-		add(ranks, gbc_ranks);
-		ranks.setColumns(10);
-		
-		Component horizontalStrut = Box.createHorizontalStrut(20);
-		GridBagConstraints gbc_horizontalStrut = new GridBagConstraints();
-		gbc_horizontalStrut.insets = new Insets(0, 0, 0, 5);
-		gbc_horizontalStrut.gridx = 8;
-		gbc_horizontalStrut.gridy = 0;
-		add(horizontalStrut, gbc_horizontalStrut);
-		
-		JButton plus = new JButton("+");
-		plus.setMargin(new Insets(2, 2, 2, 2));
-		plus.setFont(new Font("Tahoma", Font.PLAIN, 8));
-		plus.setPreferredSize(new Dimension(30, 19));
-		plus.setMinimumSize(new Dimension(30, 19));
-		GridBagConstraints gbc_plus = new GridBagConstraints();
-		gbc_plus.insets = new Insets(0, 0, 1, 5);
-		gbc_plus.gridx = 9;
-		gbc_plus.gridy = 0;
-		add(plus, gbc_plus);
-		
-		JButton minus = new JButton("-");
-		minus.setMargin(new Insets(2, 2, 2, 2));
-		minus.setFont(new Font("Tahoma", Font.PLAIN, 8));
-		minus.setMinimumSize(new Dimension(30, 19));
-		minus.setPreferredSize(new Dimension(30, 19));
-		GridBagConstraints gbc_minus = new GridBagConstraints();
-		gbc_minus.insets = new Insets(0, 0, 1, 5);
-		gbc_minus.gridx = 10;
-		gbc_minus.gridy = 0;
-		add(minus, gbc_minus);
-		
-		JTextField points = new JTextField();
-		points.setMinimumSize(new Dimension(30, 20));
-		points.setText("0");
-		points.setEditable(false);
-		GridBagConstraints gbc_points = new GridBagConstraints();
-		gbc_points.insets = new Insets(0, 0, 0, 5);
-		gbc_points.gridx = 11;
-		gbc_points.gridy = 0;
-		add(points, gbc_points);
-		points.setColumns(10);
-		
-		IntFunction<Void> lambda = (value) -> {
-			pointsAvaliable.value(pointsAvaliable.value() - (value * pointsPerRank));
-			current.value(current.value() + value);
-			points.setText(Integer.toString(current.value()));
-			ObserverDispatch.notifySubscribers(pointsAvaliable); // TODO (this)?
-			ObserverDispatch.notifySubscribers(current); // TODO (this)?
-			return null;
-		};
-		
-		plus.addActionListener((e) -> {
-			if (pointsAvaliable.value() >= pointsPerRank && current.value() < maxPoints) { lambda.apply(1); }
-		});
-		minus.addActionListener((e) -> {
-			if (current.value() > 0) { lambda.apply(-1); }
-		});
-
-		totalListener = new IndirectObservableListener<>(total,
-				(c, p) -> {
-					final int skillRanks = p.skill.getRanks().value();
-					final int mod = p.ability.map(v -> v.baseModifier()).orElse(0);
-					c.setText(StringHelper.toString(skillRanks + mod + p.points.value()));
-				});
-		
-		Component horizontalStrut_1 = Box.createHorizontalStrut(5);
-		GridBagConstraints gbc_horizontalStrut_1 = new GridBagConstraints();
-		gbc_horizontalStrut_1.gridx = 12;
-		gbc_horizontalStrut_1.gridy = 0;
-		add(horizontalStrut_1, gbc_horizontalStrut_1);
-		final Optional<Ability.Scores> ability = getAbility(chara, skill);
-		ability.ifPresent(v -> modifier.setText(StringHelper.toString(v.baseModifier())));
-		totalListener.setObserved(new TotalPacket(ability, skill, current), current);
-	}
-
-	private Optional<Ability.Scores> getAbility(final DDCharacter chara, final DDSkill skill) {
-		if (skill.getAbility().isEmpty()) { return Optional.empty(); }
-		else { return Optional.of(AbilityHelper.get(chara, skill)); }
-	}
-
-	public void applyChange() {
-		skill.spendPoints(current.value(), !isClassSkill);
-		ObserverDispatch.notifySubscribers(skill.getRanks()); // TODO (this)?
-	}
-	
-	@Override
-	public void removeNotify() {
-		super.removeNotify();
-		ObserverDispatch.unsubscribeAll(totalListener);
-	}
+@SuppressWarnings("serial")
+abstract class SkillLevelUpLine extends JPanel {
+	abstract void applyChange();
 }

+ 46 - 16
src/main/lombok/org/leumasjaffe/charsheet/view/skills/SkillLevelUpPanel.java

@@ -4,8 +4,9 @@ import java.awt.Dimension;
 import java.awt.GridBagConstraints;
 import java.awt.GridBagLayout;
 import java.awt.Insets;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
 
 import javax.swing.JLabel;
 import javax.swing.JPanel;
@@ -16,6 +17,7 @@ import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.model.skill.DDSkill;
 import org.leumasjaffe.charsheet.model.skill.DDSkills;
 import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
@@ -23,17 +25,29 @@ import org.leumasjaffe.observer.ObserverDispatch;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.experimental.FieldDefaults;
+import javax.swing.ScrollPaneConstants;
 
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public abstract class SkillLevelUpPanel extends JPanel {
 	ObservableListener<JTextField, IntValue> purchaseListener;
+	ObservableListener<SkillLevelUpPanel, DDSkills> listener;
+	
 	@Getter(AccessLevel.PROTECTED) JPanel panel;
-	List<SkillLevelUpLine> lines;
+	JPanel skillPanel;
+	Map<String, SkillLevelUpLine> lines = new TreeMap<>();
+	
+	DDCharacter ddChara;
+	DDCharacterClass ddClass;
+	DDSkills skills;
+	IntValue pointsAvailable;
 
 	public SkillLevelUpPanel(final DDCharacter chara, final DDCharacterClass cclass) {
-		final IntValue pointsAvaliable = new IntValue(Math.max(1, cclass.getSkillPoints() + 
-				chara.getAbilities().getInt().baseModifier()));
+		this.ddChara = chara;
+		this.ddClass = cclass;
+		this.skills = ddChara.getSkills();
+		this.pointsAvailable = new IntValue(Math.max(1, ddClass.getSkillPoints() + 
+				ddChara.getAbilities().getInt().baseModifier()));
 		
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0};
@@ -75,41 +89,57 @@ public abstract class SkillLevelUpPanel extends JPanel {
 		pointsRemaining.setColumns(10);
 		
 		JScrollPane scrollPane = new JScrollPane();
+		scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
 		GridBagConstraints gbc_scrollPane = new GridBagConstraints();
 		gbc_scrollPane.fill = GridBagConstraints.BOTH;
 		gbc_scrollPane.gridx = 0;
 		gbc_scrollPane.gridy = 1;
 		add(scrollPane, gbc_scrollPane);
 		
-		JPanel skillPanel = new JPanel();
-		skillPanel.setMinimumSize(new Dimension(300, 200));
+		skillPanel = new JPanel();
+		scrollPane.setPreferredSize(new Dimension(480, 300));
 		scrollPane.setViewportView(skillPanel);
 		skillPanel.setLayout(new VerticalLayout());
-				
-		lines = new ArrayList<>();
-		final DDSkills skills = chara.getSkills();
-		skills.getSkills().stream().forEach( skill -> {
-			SkillLevelUpLine line = new SkillLevelUpLine(chara, cclass, skill, pointsAvaliable);
-			skillPanel.add(line);
-			lines.add(line);
+		
+		listener = new ObservableListener<>(this, (c, v) -> {
+			v.getSkills().stream().forEach(skill -> {
+				c.lines.computeIfAbsent(skill.getName(), k -> createSkillLine(skill));
+			});
+			c.skillPanel.removeAll();
+			c.lines.values().forEach(c.skillPanel::add);
+			c.revalidate();
+			c.repaint();
 		});
+		listener.setObserved(skills);
 		
 		purchaseListener = new ObservableListener<>(pointsRemaining, (c, v) -> {
 			setIsReady(v.value() == 0);
 			c.setText(Integer.toString(v.value()));
 		});
-		purchaseListener.setObserved(pointsAvaliable);
+		purchaseListener.setObserved(pointsAvailable);
+	}
+
+	private SkillLevelUpLine createSkillLine(DDSkill skill) {
+		return skill.isWildcardSkill()
+				? new WildcardSkillLevelUpLine(ddChara, ddClass, skills, skill)
+				: new NormalSkillLevelUpLine(ddChara, ddClass, skill, pointsAvailable);
 	}
 	
 	protected abstract void setIsReady(boolean b);
 	
 	public void commitAllChanges() {
-		lines.forEach(SkillLevelUpLine::applyChange);
+		lines.values().forEach(SkillLevelUpLine::applyChange);
+		skills.getSkills().stream().filter(
+				sk -> sk.isFromWildcardSkill() && sk.getPointsSpent() == 0)
+		.collect(Collectors.toList()).forEach(skills::removeSkill);
+		ObserverDispatch.notifySubscribers(ddChara);
+		listener.notifySubscribers(skills);
 	}
 	
 	@Override
 	public void removeNotify() {
 		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
 		ObserverDispatch.unsubscribeAll(purchaseListener);
 	}
 }

+ 216 - 0
src/main/lombok/org/leumasjaffe/charsheet/view/skills/WildcardSkillLevelUpLine.java

@@ -0,0 +1,216 @@
+package org.leumasjaffe.charsheet.view.skills;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.model.skill.DDSkill;
+import org.leumasjaffe.charsheet.model.skill.DDSkills;
+import org.leumasjaffe.charsheet.util.AbilityHelper;
+import org.leumasjaffe.observer.ObserverDispatch;
+
+import lombok.AccessLevel;
+import lombok.Value;
+import lombok.experimental.FieldDefaults;
+
+import java.awt.GridBagLayout;
+
+import javax.swing.Box;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import java.awt.GridBagConstraints;
+import javax.swing.JLabel;
+import java.awt.Insets;
+import java.util.Optional;
+import java.awt.Dimension;
+import java.awt.Font;
+
+import javax.swing.JTextField;
+import java.awt.Color;
+import java.awt.Component;
+
+import javax.swing.border.MatteBorder;
+import javax.swing.SwingConstants;
+import javax.swing.JPanel;
+
+@SuppressWarnings("serial")
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class WildcardSkillLevelUpLine extends SkillLevelUpLine {	
+	@Value
+	private static final class TotalPacket {
+		Optional<Ability.Scores> ability;
+		DDSkill skill;
+		IntValue points;
+	}
+	
+	public WildcardSkillLevelUpLine(final DDCharacter chara, final DDCharacterClass cclass, 
+			final DDSkills skillList, final DDSkill skill) {
+		
+		setBorder(new MatteBorder(0, 0, 1, 0, (Color) new Color(0, 0, 0)));
+		setPreferredSize(new Dimension(480, 22));
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{1.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JCheckBox checkBoxIsClassSkill = new JCheckBox("");
+		checkBoxIsClassSkill.setToolTipText("Class Skill?");
+		checkBoxIsClassSkill.setSelected(cclass.isClassSkill(skill.getName()));
+		checkBoxIsClassSkill.setEnabled(false);
+		GridBagConstraints gbc_checkBoxIsClassSkill = new GridBagConstraints();
+		gbc_checkBoxIsClassSkill.insets = new Insets(1, 0, 0, 5);
+		gbc_checkBoxIsClassSkill.gridx = 0;
+		gbc_checkBoxIsClassSkill.gridy = 0;
+		add(checkBoxIsClassSkill, gbc_checkBoxIsClassSkill);
+		
+		JPanel panel = new JPanel();
+		GridBagConstraints gbc_panel = new GridBagConstraints();
+		panel.setMaximumSize(new Dimension(150, 14));
+		panel.setMinimumSize(new Dimension(150, 14));
+		panel.setPreferredSize(new Dimension(150, 14));
+		gbc_panel.insets = new Insets(1, 0, 0, 5);
+		gbc_panel.fill = GridBagConstraints.BOTH;
+		gbc_panel.gridx = 1;
+		gbc_panel.gridy = 0;
+		add(panel, gbc_panel);
+		GridBagLayout gbl_panel = new GridBagLayout();
+		gbl_panel.columnWidths = new int[]{0, 0, 0, 0};
+		gbl_panel.rowHeights = new int[]{0, 0};
+		gbl_panel.columnWeights = new double[]{0.0, 1.0, 0.0, Double.MIN_VALUE};
+		gbl_panel.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		panel.setLayout(gbl_panel);
+				
+		final String skillLead = skill.getName().replaceAll("\\(.*", "(");
+		JLabel lblName = new JLabel(skillLead);
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.anchor = GridBagConstraints.EAST;
+		gbc_lblName.gridx = 0;
+		gbc_lblName.gridy = 0;
+		panel.add(lblName, gbc_lblName);
+		
+		JTextField textField = new JTextField();
+		textField.setMinimumSize(new Dimension(10, 20));
+		textField.setPreferredSize(new Dimension(10, 20));
+		GridBagConstraints gbc_textField = new GridBagConstraints();
+		gbc_textField.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textField.gridx = 1;
+		gbc_textField.gridy = 0;
+		panel.add(textField, gbc_textField);
+		textField.setColumns(10);
+		
+		JLabel lblName2 = new JLabel(")");
+		GridBagConstraints gbc_lblName2 = new GridBagConstraints();
+		gbc_lblName2.gridx = 2;
+		gbc_lblName2.gridy = 0;
+		panel.add(lblName2, gbc_lblName2);
+		
+		JLabel lblAbil = new JLabel(skill.getAbility());
+		lblAbil.setMaximumSize(new Dimension(30, 14));
+		lblAbil.setMinimumSize(new Dimension(30, 14));
+		lblAbil.setPreferredSize(new Dimension(30, 14));
+		GridBagConstraints gbc_lblAbil = new GridBagConstraints();
+		gbc_lblAbil.insets = new Insets(1, 0, 0, 5);
+		gbc_lblAbil.anchor = GridBagConstraints.EAST;
+		gbc_lblAbil.gridx = 2;
+		gbc_lblAbil.gridy = 0;
+		add(lblAbil, gbc_lblAbil);
+		
+		String modStr = Integer.toString(getAbility(chara, skill).map(Ability.Scores::modifier).orElse(0));
+		JTextField total = new JTextField(modStr);
+		total.setToolTipText("Skill Modifier");
+		total.setHorizontalAlignment(SwingConstants.CENTER);
+		total.setEditable(false);
+		total.setMinimumSize(new Dimension(30, 20));
+		total.setMaximumSize(new Dimension(30, 20));
+		total.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_total = new GridBagConstraints();
+		gbc_total.insets = new Insets(1, 0, 0, 5);
+		gbc_total.fill = GridBagConstraints.HORIZONTAL;
+		gbc_total.gridx = 3;
+		gbc_total.gridy = 0;
+		add(total, gbc_total);
+		total.setColumns(10);
+		
+		JLabel label = new JLabel("=");
+		GridBagConstraints gbc_label = new GridBagConstraints();
+		gbc_label.anchor = GridBagConstraints.EAST;
+		gbc_label.insets = new Insets(1, 0, 0, 5);
+		gbc_label.gridx = 4;
+		gbc_label.gridy = 0;
+		add(label, gbc_label);
+		
+		JTextField modifier = new JTextField(modStr);
+		modifier.setToolTipText("Ability Modifier");
+		modifier.setHorizontalAlignment(SwingConstants.CENTER);
+		modifier.setEditable(false);
+		modifier.setMinimumSize(new Dimension(30, 20));
+		modifier.setMaximumSize(new Dimension(30, 20));
+		modifier.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_modifier = new GridBagConstraints();
+		gbc_modifier.insets = new Insets(1, 0, 0, 5);
+		gbc_modifier.fill = GridBagConstraints.HORIZONTAL;
+		gbc_modifier.gridx = 5;
+		gbc_modifier.gridy = 0;
+		add(modifier, gbc_modifier);
+		modifier.setColumns(10);
+		
+		JLabel label_1 = new JLabel("+");
+		GridBagConstraints gbc_label_1 = new GridBagConstraints();
+		gbc_label_1.anchor = GridBagConstraints.EAST;
+		gbc_label_1.insets = new Insets(1, 0, 0, 5);
+		gbc_label_1.gridx = 6;
+		gbc_label_1.gridy = 0;
+		add(label_1, gbc_label_1);
+		
+		JTextField ranks = new JTextField("0");
+		ranks.setToolTipText("Ranks");
+		ranks.setHorizontalAlignment(SwingConstants.CENTER);
+		ranks.setEditable(false);
+		ranks.setMinimumSize(new Dimension(30, 20));
+		ranks.setMaximumSize(new Dimension(30, 20));
+		ranks.setPreferredSize(new Dimension(30, 20));
+		GridBagConstraints gbc_ranks = new GridBagConstraints();
+		gbc_ranks.insets = new Insets(1, 0, 0, 5);
+		gbc_ranks.fill = GridBagConstraints.HORIZONTAL;
+		gbc_ranks.gridx = 7;
+		gbc_ranks.gridy = 0;
+		add(ranks, gbc_ranks);
+		ranks.setColumns(10);
+		
+		Component horizontalStrut = Box.createHorizontalStrut(10);
+		GridBagConstraints gbc_horizontalStrut = new GridBagConstraints();
+		gbc_horizontalStrut.gridx = 8;
+		gbc_horizontalStrut.gridy = 0;
+		add(horizontalStrut, gbc_horizontalStrut);
+		
+		JButton addSkill = new JButton("Add Skill");
+		addSkill.setMargin(new Insets(2, 2, 2, 2));
+		addSkill.setFont(new Font("Tahoma", Font.PLAIN, 8));
+		addSkill.setPreferredSize(new Dimension(55, 19));
+		addSkill.setMinimumSize(new Dimension(55, 19));
+		GridBagConstraints gbc_plus = new GridBagConstraints();
+		gbc_plus.insets = new Insets(1, 0, 0, 5);
+		gbc_plus.gridx = 9;
+		gbc_plus.gridy = 0;
+		add(addSkill, gbc_plus);
+		
+		addSkill.addActionListener(e -> {
+			final String input = textField.getText();
+			if (input.isEmpty()) { return; }
+			textField.setText("");
+			DDSkill newSkill = skillList.getSkill(skillLead + input + ")");
+			if (newSkill.getPointsSpent() == 0) { // Don't notify if I typed a skill I already know
+				ObserverDispatch.notifySubscribers(skillList);
+			}
+		});
+	}
+
+	private Optional<Ability.Scores> getAbility(final DDCharacter chara, final DDSkill skill) {
+		if (skill.getAbility() == null || skill.getAbility().isEmpty()) { return Optional.empty(); }
+		else { return Optional.of(AbilityHelper.get(chara, skill)); }
+	}
+
+	public void applyChange() {	}
+}

+ 5 - 3
src/main/lombok/org/leumasjaffe/charsheet/view/summary/InitiativeLine.java

@@ -11,7 +11,9 @@ import javax.swing.border.LineBorder;
 import org.leumasjaffe.charsheet.config.Constants;
 import org.leumasjaffe.charsheet.model.Ability;
 import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.features.GroupedBonus;
 import org.leumasjaffe.charsheet.observer.helper.AbilModStringify;
+import org.leumasjaffe.collections.Tree;
 import org.leumasjaffe.format.StringHelper;
 import org.leumasjaffe.observer.IndirectObservableListener;
 import org.leumasjaffe.observer.ObservableListener;
@@ -141,9 +143,9 @@ public class InitiativeLine extends JPanel {
 	}
 
 	private int getInitiativeBonuses(DDCharacter v) {
-		// FIXME
-		return v.getFeatureBonuses(Constants.INITIATIVE).stream()
-				.mapToInt(f -> (Integer) f.value()).sum();
+		Tree<String, GroupedBonus> props = new Tree<>();
+		v.getFeatureBonuses(Constants.INITIATIVE).stream().forEach(f -> f.accumulate(props));
+		return props.getOrDefault(new GroupedBonus()).intValue();
 	}
 
 	public void setModel(DDCharacter model) {

+ 32 - 0
src/main/lombok/org/leumasjaffe/collections/Tree.java

@@ -0,0 +1,32 @@
+package org.leumasjaffe.collections;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class Tree<K, V> {
+	V value = null;
+	Map<K, Tree<K, V>> leaves = new HashMap<>();
+	
+	public V get() {
+		return value;
+	}
+	
+	public V getOrDefault(V def) {
+		return get() == null ? def : get();
+	}
+
+	public V put(V newVal) {
+		V tmp = get();
+		value = newVal;
+		return tmp;
+	}
+	
+	public V putIfAbsent(V newVal) {
+		return get() == null ? put(newVal) : get();
+	}
+	
+	public Tree<K, V> get(K key) {
+		leaves.putIfAbsent(key, new Tree<>());
+		return leaves.get(key);
+	}
+}