瀏覽代碼

Merge branch 'spell_picker'

* spell_picker:
  Make level dialog so that you do not need to learn all spells before you are able to prepare spells. Instead, "<none>"s will be added for every empty slot and the system will automatically update the list when you edit the Known group.
  Remove unneeded include
  Move responsibility of fetching of available spells, tuple data, and allowsDuplicates boolean into SpellPicker
  Simplifying SelectSpellsPanel functionality by moving the 'toPrepare' minimum size rule to the only place that actually needs it.
  Remove unneeded imports
  Start work on making Domain spells pickable on level
  Allowing Cleric Domain to be fetched in a new class construction
  Fixing bug in PrepareSpellsDialog closing
  Move the IntValue wrapper outside of DDSpellbook, since it doesn't belong there.
  Relocate equipitemcontroller
  Fixing various issues's leftover code that was not correctly merged
  Fixing a bug in spell preparation
  Fixing all compiler errors from merge
  Add 3rd level spell for future use.
  Remove unneeded Custom Area
  Altering Secondary spellbooks. - Only one allowed - Obtains callback to primary book, so Specialist Wizard can do things.
  Add initial support for cleric Domains. This allows a class to have multiple spell books (though in practice, it shouldn't ever be more than 2) to support concepts like specialist schools.
Sam Jaffe 8 年之前
父節點
當前提交
5bcac0f2ae
共有 38 個文件被更改,包括 747 次插入314 次删除
  1. 40 31
      resources/Potato.json
  2. 1 1
      resources/classes/Cleric.json
  3. 64 0
      resources/spells/default.json
  4. 15 0
      resources/spells/domain/plant.json
  5. 23 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/ChooseSpellTuple.java
  6. 38 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/LearnSpellPicker.java
  7. 25 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareDomainSpellPicker.java
  8. 29 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareNewlyLearnedSpellPicker.java
  9. 31 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareSpellPicker.java
  10. 14 0
      src/main/lombok/org/leumasjaffe/charsheet/controller/magic/SpellPicker.java
  11. 29 1
      src/main/lombok/org/leumasjaffe/charsheet/model/DDCharacterClass.java
  12. 5 5
      src/main/lombok/org/leumasjaffe/charsheet/model/DDClass.java
  13. 22 5
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpell.java
  14. 1 1
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpellFactory.java
  15. 13 0
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpellbook.java
  16. 1 1
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Area.java
  17. 1 1
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Duration.java
  18. 98 0
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Domain.java
  19. 5 0
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Inspired.java
  20. 0 1
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Researched.java
  21. 1 0
      src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Spontaneous.java
  22. 4 1
      src/main/lombok/org/leumasjaffe/charsheet/observer/ObserverHelper.java
  23. 2 2
      src/main/lombok/org/leumasjaffe/charsheet/view/ClassTab.java
  24. 14 8
      src/main/lombok/org/leumasjaffe/charsheet/view/D20Sheet.java
  25. 3 2
      src/main/lombok/org/leumasjaffe/charsheet/view/DialogBuilder.java
  26. 17 45
      src/main/lombok/org/leumasjaffe/charsheet/view/level/LevelUpSpellPanel.java
  27. 74 60
      src/main/lombok/org/leumasjaffe/charsheet/view/level/UpdateClassWithLevelPanel.java
  28. 10 4
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/PrepareSpellsDialog.java
  29. 0 26
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SelectPreparedSpellsPanel.java
  30. 84 90
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SelectSpellsPanel.java
  31. 2 1
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java
  32. 24 6
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellLevelPanel.java
  33. 2 1
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellLevelPerDayPanel.java
  34. 4 2
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellLine.java
  35. 7 6
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellMenu.java
  36. 16 3
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellPanel.java
  37. 5 1
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellsKnownHeader.java
  38. 23 9
      src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellsPerDayHeader.java

+ 40 - 31
resources/Potato.json

@@ -5,45 +5,54 @@
     {
       "level": 3,
       "name": "Cleric",
-      "spellBook": {
-        "@c": ".impl.Inspired",
-        "classRef": "Cleric",
-        "spellInfo": {
-          "0": {
-            "spellsPerDay": 4,
-            "spellsPrepared": [
-              "Create Water"
-            ],
-            "spellsPreparedPreviously": [
-              "Create Water",
-              "Create Water",
-              "Create Water",
-              "Create Water"
-            ]
-          },
-          "1": {
-            "spellsPerDay": 2,
-            "spellsPrepared": [
-              "Cure Light Wounds"
-            ],
-            "spellsPreparedPreviously": [
-              "Cure Light Wounds",
-              "Cure Light Wounds"
-            ]
-          },
-          "2": {
-            "spellsPerDay": 1,
-            "spellsPrepared": [],
-            "spellsPreparedPreviously": []
+      "spellBook": [
+        {
+          "@c": ".impl.Inspired",
+          "classRef": "Cleric",
+          "spellInfo": {
+            "0": {
+              "spellsPerDay": 4,
+              "spellsPrepared": [
+                "Create Water"
+              ],
+              "spellsPreparedPreviously": [
+                "Create Water",
+                "Create Water",
+                "Create Water",
+                "Create Water"
+              ]
+            },
+            "1": {
+              "spellsPerDay": 2,
+              "spellsPrepared": [
+                "Cure Light Wounds"
+              ],
+              "spellsPreparedPreviously": [
+                "Cure Light Wounds",
+                "Cure Light Wounds"
+              ]
+            },
+            "2": {
+              "spellsPerDay": 1,
+              "spellsPrepared": [],
+              "spellsPreparedPreviously": []
+            }
           }
+        },
+        {
+          "@c": ".impl.Domain",
+          "domains": ["Plant"],
+          "spellsPrepared": ["Entangle", "Barkskin"],
+          "spellsPreparedPreviously": ["Entangle", "Barkskin"]
         }
-      }
+      ]
     },
     {
       "level": 2,
       "name": "Bard",
       "spellBook": {
         "@c": ".impl.Spontaneous",
+        "name":"Bard",
         "spellInfo": {
           "0": {
             "spellsPerDay": 3,

+ 1 - 1
resources/classes/Cleric.json

@@ -27,7 +27,7 @@
     "Spellcraft"
   ],
   "spells":{
-    "spellBookTypeName":"{\"@c\":\".impl.Inspired\",\"classRef\":\"Cleric\",\"spellInfo\":{}}",
+    "spellBookTypeName":"[{\"@c\":\".impl.Inspired\",\"classRef\":\"Cleric\",\"spellInfo\":{}},{\"@c\":\".impl.Domain\"}]",
     "group":"DIVINE",
     "ability":"WIS",
     "perDay":[

File diff suppressed because it is too large
+ 64 - 0
resources/spells/default.json


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

@@ -0,0 +1,15 @@
+{
+  "name":"Domain::Plant",
+  "powers":[],
+  "spells":[
+    "Entangle",
+    "Barkskin",
+    "Plant Growth",
+    "Command Plants",
+    "Wall of Thorns",
+    "Repel Wood",
+    "Animate Plants",
+    "Control Plants",
+    "Shambler"
+  ]
+}

+ 23 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/ChooseSpellTuple.java

@@ -0,0 +1,23 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.util.AbilityHelper;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.experimental.FieldDefaults;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PUBLIC, makeFinal=true)
+public class ChooseSpellTuple {
+	DDCharacter chara;
+	DDCharacterClass dclass;
+	DDSpellbook spellBook;
+	
+	public Ability.Scores ability() {
+		return AbilityHelper.get(chara, dclass);
+	}
+}

+ 38 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/LearnSpellPicker.java

@@ -0,0 +1,38 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.observer.Observable;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class LearnSpellPicker extends Observable.Instance implements SpellPicker {
+	@Getter ChooseSpellTuple info;
+	
+	@Override
+	public boolean allowsDuplicates() {
+		return false;
+	}
+	
+	@Override
+	public List<Integer> getSpellCounts(int level) {
+		if (level <= 0) return Collections.emptyList();
+		return info.dclass.getProto().getSpells().get().getKnown().get(level-1);
+	}
+	
+	@Override
+	public Collection<DDSpell> getAvailableSpells(int i) {
+		Collection<DDSpell> spells = new ArrayList<>(info.dclass.getProto().getSpellList(i));
+		spells.removeAll(info.spellBook.spellsKnownAtLevel(i));
+		return spells;
+	}
+}

+ 25 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareDomainSpellPicker.java

@@ -0,0 +1,25 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class PrepareDomainSpellPicker extends PrepareSpellPicker {	
+	
+	public PrepareDomainSpellPicker(ChooseSpellTuple info) {
+		super(info);
+	}
+
+	@Override
+	public List<Integer> getSpellCounts(int level) {
+		// FIXME: This only works if there's only ever 1 bonus spell per circle
+		// FIXME: This doesn't handle the fact that domains don't have a bonus 0-th level
+		// FIXME: Secondary Spellbooks are based on feature activation...
+		// TODO: Select Cleric Domains
+		// TODO: Select Wizard Schools
+		return super.getSpellCounts(level).stream().map(i -> i != 0 ? 1 : 0).collect(Collectors.toList());
+	}
+}

+ 29 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareNewlyLearnedSpellPicker.java

@@ -0,0 +1,29 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.function.IntFunction;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class PrepareNewlyLearnedSpellPicker extends PrepareSpellPicker {	
+	IntFunction<Collection<DDSpell>> getNewSpells;
+	
+	public PrepareNewlyLearnedSpellPicker(ChooseSpellTuple info, IntFunction<Collection<DDSpell>> func) {
+		super(info);
+		this.getNewSpells = func;
+	}
+
+	@Override
+	public Collection<DDSpell> getAvailableSpells(int level) {
+		final Collection<DDSpell> start = new ArrayList<>(super.getAvailableSpells(level));
+		if (getNewSpells.apply(level) != null) {
+			start.addAll(getNewSpells.apply(level));
+		}
+		return start;
+	}
+}

+ 31 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/PrepareSpellPicker.java

@@ -0,0 +1,31 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.observer.Observable;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class PrepareSpellPicker extends Observable.Instance implements SpellPicker {
+	@Getter ChooseSpellTuple info;
+	
+	@Override
+	public List<Integer> getSpellCounts(int level) {
+		// TODO: Bonus spells for high ability scores
+		if (level <= 0) return Collections.emptyList();
+		return info.dclass.getProto().getSpells().get().getPerDay().get(level-1);
+	}
+	
+	@Override
+	public Collection<DDSpell> getAvailableSpells(int level) {
+		return info.spellBook.spellsKnownAtLevel(level);
+	}
+}

+ 14 - 0
src/main/lombok/org/leumasjaffe/charsheet/controller/magic/SpellPicker.java

@@ -0,0 +1,14 @@
+package org.leumasjaffe.charsheet.controller.magic;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.observer.Observable;
+
+public interface SpellPicker extends Observable {
+	ChooseSpellTuple getInfo();
+	default boolean allowsDuplicates() { return true; }
+	public List<Integer> getSpellCounts(int level);
+	public Collection<DDSpell> getAvailableSpells(int level);
+}

+ 29 - 1
src/main/lombok/org/leumasjaffe/charsheet/model/DDCharacterClass.java

@@ -11,7 +11,9 @@ import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
 import org.leumasjaffe.observer.Observable;
 
+import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonValue;
 
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
@@ -53,7 +55,33 @@ public class DDCharacterClass extends Observable.Instance implements Comparable<
 //	@NonNull List<Integer> healthRolls;
 	@Delegate @Getter(AccessLevel.NONE) Reference name;
 	
-	Optional<DDSpellbook> spellBook;
+	@Getter
+	public static class DDSpellbookWrapper {
+		@JsonCreator
+		public DDSpellbookWrapper(DDSpellbook main) {
+			this.main = main;
+		}
+		
+		@JsonCreator
+		public DDSpellbookWrapper(List<DDSpellbook> books) {
+			this.main = books.get(0);
+			if (books.size() > 1) {
+				this.secondary = Optional.of(books.get(1));
+				DDSpellbook.Secondary.class.cast(books.get(1)).setMainSpellbook(main);
+			}
+		}
+		
+		@JsonValue
+		private DDSpellbook[] actual() {
+			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;
 	
 	public DDCharacterClass(String name) {
 		this.level = new IntValue(0);

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

@@ -10,11 +10,11 @@ import java.util.Map;
 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.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpellList;
 import org.leumasjaffe.charsheet.model.magic.DDSpellList.SpellList;
-import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.format.StringFormatter;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@@ -83,15 +83,15 @@ public class DDClass {
 			Collections.unmodifiableList(features.get(level-1));
 	}
 
-	public Optional<DDSpellbook> createNewSpellBook()  {
+	public Optional<DDSpellbookWrapper> createNewSpellBook()  {
 		return spells.flatMap(sl -> sl.getSpellBookTypeName()).map(st -> createSpellbookImpl(st));
 	}
 
 	@SneakyThrows({JsonMappingException.class, JsonParseException.class, IOException.class})
-	private DDSpellbook createSpellbookImpl(String st) {
-		if (!st.startsWith("{")) {
+	private DDSpellbookWrapper createSpellbookImpl(String st) {
+		if (!st.startsWith("[") && !st.startsWith("{")) {
 			st = new StringFormatter("{{\"@c\":\"{}\"}}").format(st);
 		}
-		return mapper.readValue(st.getBytes(), DDSpellbook.class);
+		return mapper.readValue(st.getBytes(), DDSpellbookWrapper.class);
 	}
 }

+ 22 - 5
src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpell.java

@@ -1,9 +1,10 @@
 package org.leumasjaffe.charsheet.model.magic;
 
 import java.util.EnumSet;
-import java.util.Map;
+import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
+import java.util.SortedMap;
 
 import org.leumasjaffe.charsheet.model.magic.dimension.Area;
 import org.leumasjaffe.charsheet.model.magic.dimension.Duration;
@@ -16,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonValue;
 
 import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.Getter;
 import lombok.NonNull;
@@ -64,10 +66,10 @@ public class DDSpell {
 	}
 	
 	@NonNull String name;
-	@NonNull @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) Map<String, Integer> classToLevel;
+	@NonNull @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) SortedMap<String, Integer> classToLevel;
 	@NonNull School school;
 	SubSchool subSchool;
-	@NonNull Set<String> keywords;
+	@NonNull Set<String> keywords = new HashSet<>();
 	
 	@NonNull EnumSet<Component> components = EnumSet.noneOf(Component.class);
 	@NonNull DDActionType castingTime;
@@ -84,8 +86,23 @@ public class DDSpell {
 		return subSchool == null ? school.toString() : school.toString() + " (" + subSchool.toString() + ")";
 	}
 	
-	public int getClassLevel(final String clas) {
-		return classToLevel.get(clas);
+	@AllArgsConstructor
+	@FieldDefaults(level=AccessLevel.PUBLIC, makeFinal=true)
+	public static class SpellClassInfo {
+		String subClass;
+		int level;
+		public String toString() {
+			return (subClass.isEmpty() ? "" : "[" + subClass + "] ") + level;
+		}
+	}
+	
+	public SpellClassInfo getClassLevel(final String clas) {
+		final SortedMap<String, Integer> sub = classToLevel.tailMap(clas);
+		if (sub.firstKey().equals(clas)) { return new SpellClassInfo("", sub.get(clas)); }
+		else if (sub.firstKey().startsWith(clas)) { 
+			return new SpellClassInfo(sub.firstKey().substring(clas.length() + 2), sub.get(sub.firstKey()));
+		}
+		return new SpellClassInfo("", -1);
 	}
 	
 	@JsonValue public String getName() {

+ 1 - 1
src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpellFactory.java

@@ -29,10 +29,10 @@ final class DDSpellFactory {
 	
 	static {
 		mapper.registerModule(new Jdk8Module());
-		loadIfAbsent("resources/spells/default.json");
 	}
 	
 	public DDSpell loadSpell(final String name) {
+		loadIfAbsent("resources/spells/default.json");
 		return spellStore.get(name);
 	}
 	

+ 13 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/magic/DDSpellbook.java

@@ -9,10 +9,18 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
 
+import lombok.Getter;
 import lombok.NonNull;
+import lombok.Setter;
 
 @JsonTypeInfo(use = Id.MINIMAL_CLASS)
 public abstract class DDSpellbook extends Observable.Instance {
+	public interface Secondary {
+		DDSpellbook getMainSpellbook();
+		void setMainSpellbook(DDSpellbook spellBook);
+	}
+	
+	@Getter @Setter String name;
 	@NonNull public abstract Collection<DDSpell> spellsKnownAtLevel( int level );
 	@NonNull public abstract List<DDSpell> spellsPreparedAtLevel( int level );
 	
@@ -36,6 +44,11 @@ public abstract class DDSpellbook extends Observable.Instance {
 	
 	public abstract void castSpell( int level, final DDSpell spell );
 	
+	@JsonIgnore
+	public String getSingleName() {
+		return getName();
+	}
+
 	public void learnSpells(int level, Collection<DDSpell> known) {
 		throw new UnsupportedOperationException("This class does not have a list of known spells to edit");
 	}

+ 1 - 1
src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Area.java

@@ -64,7 +64,7 @@ public interface Area {
 			switch (emit) {
 			case NONE: return "sphere";
 			case EMANATION:	return "spherical " + emit;
-			default: return emit.toString();
+			default: return emit.toString().toLowerCase();
 			}
 		}
 	}

+ 1 - 1
src/main/lombok/org/leumasjaffe/charsheet/model/magic/dimension/Duration.java

@@ -83,7 +83,7 @@ public interface Duration {
 		public String toString() {
 			final __Pair p = new __Pair(measure, length, per);
 			final StringBuilder str = new StringBuilder(StringHelper.format("{} {3}{0>1?s:} + {} {}{1>1?s:}/{?level:{} levels}",
-					p.value[0], p.value[1], p.measure, step));
+					p.value[0], p.value[1], p.measure, step == 1, step));
 			return str.toString();
 		}
 	}

+ 98 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Domain.java

@@ -0,0 +1,98 @@
+package org.leumasjaffe.charsheet.model.magic.impl;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.SneakyThrows;
+
+@Data @EqualsAndHashCode(callSuper=true)
+public class Domain extends Prepared implements DDSpellbook.Secondary {
+	@Data @EqualsAndHashCode
+	private static class SpellBookImpl {
+		@JsonCreator @SneakyThrows
+		public static SpellBookImpl create(String name) {
+			ObjectMapper mapper = new ObjectMapper();
+			mapper.registerModule(new Jdk8Module());
+			return mapper.readValue(new File("resources/spells/domain/" + name.toLowerCase() + ".json"), 
+					new TypeReference<SpellBookImpl>() {});
+		}
+		@JsonValue private String getImplName() { return name.replaceAll(".*::", ""); }
+		String name;
+		List<Object> powers;
+		List<DDSpell> spells;
+	}
+	
+	@JsonIgnore DDSpellbook mainSpellbook;
+	Set<SpellBookImpl> domains = new LinkedHashSet<>();
+	List<DDSpell> spellsPrepared, spellsPreparedPreviously;
+	
+	@Override
+	public String getName() {
+		return mainSpellbook.getName() + " " + getSingleName();
+	}
+	
+	@Override
+	public String getSingleName() {
+		return "Domain";
+	}
+
+	@Override
+	public Collection<DDSpell> spellsKnownAtLevel(int level) {
+		if (level == 0) return Collections.emptyList();
+		return domains.stream().map(d -> d.spells.get(level-1)).collect(Collectors.toList());
+	}
+
+	@Override
+	public List<DDSpell> spellsPreparedAtLevel(int level) {
+		if (spellsPreparedPreviously.size() < level || level == 0) return Collections.emptyList();
+		final DDSpell spell = spellsPrepared.get(level-1);
+		return spell == null ? Collections.emptyList() : Collections.singletonList(spell);
+	}
+
+	@Override
+	public int numSpellsPerDayAtLevel(int level) {
+		return mainSpellbook.numSpellsKnownAtLevel(level) != 0 ? 1 : 0;
+	}
+
+	@Override
+	public void castSpell(int level, DDSpell spell) {
+		if (!Objects.equals(spellsPrepared.get(level-1), spell)) {
+			throw new IllegalArgumentException("Casting a domain spell that was not prepared");
+		}
+		spellsPrepared.set(level-1, null);
+	}
+
+	@Override
+	public void prepareSpells(int level, Collection<DDSpell> spells) {
+		if (level == 0) return;
+		if (spells.size() != 1) {
+			throw new IllegalArgumentException("Can only prepare one domain spell per domain class");
+		}
+		spellsPrepared.set(level-1, spells.iterator().next());
+		spellsPreparedPreviously.set(level-1, spells.iterator().next());
+	}
+
+	@Override
+	public List<DDSpell> getSpellsPreparedPreviouslyForLevel(int level) {
+		if (spellsPreparedPreviously.size() < level || level == 0) return Collections.emptyList();
+		return Collections.singletonList(spellsPreparedPreviously.get(level-1));
+	}
+}

+ 5 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Inspired.java

@@ -43,6 +43,11 @@ public class Inspired extends Prepared {
 	@NonNull Map<Integer, Inspired.Level> spellInfo;
 	@NonNull ClassReference classRef;
 
+	@Override
+	public String getName() {
+		return classRef.ref.getName();
+	}
+	
 	@Override
 	public int numSpellsPerDayAtLevel( int level ) {
 		return get(level).spellsPerDay;

+ 0 - 1
src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Researched.java

@@ -8,7 +8,6 @@ import java.util.Map;
 
 import org.leumasjaffe.charsheet.model.DDClass;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
-import org.leumasjaffe.charsheet.model.observable.IntValue;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonValue;

+ 1 - 0
src/main/lombok/org/leumasjaffe/charsheet/model/magic/impl/Spontaneous.java

@@ -79,6 +79,7 @@ public class Spontaneous extends DDSpellbook {
 
 	@Override
 	public void prepareSpells(int level, Collection<DDSpell> spells) {
+		if (spells == null) return;
 		final Level lInfo = get(level);
 		lInfo.spellsPerDayRemaining = lInfo.spellsPerDay = spells.size();
 	}

+ 4 - 1
src/main/lombok/org/leumasjaffe/charsheet/observer/ObserverHelper.java

@@ -11,7 +11,10 @@ public class ObserverHelper {
 	public void notifyObservableHierarchy(final DDCharacterClass dclass, final Object src) {
 		ObserverDispatch.notifySubscribers(dclass);
 		// FIXME
-		dclass.getSpellBook().ifPresent(sb -> ObserverDispatch.notifySubscribers(sb));
+		dclass.getSpellBook().ifPresent(wrap -> {
+			ObserverDispatch.notifySubscribers(wrap.getMain());
+			wrap.getSecondary().ifPresent(sb -> ObserverDispatch.notifySubscribers(sb));
+		});
 	}
 	
 	public void notifyObservableHierarchy(final Ability.Scores abil, final Object src) {

+ 2 - 2
src/main/lombok/org/leumasjaffe/charsheet/view/ClassTab.java

@@ -173,14 +173,14 @@ public class ClassTab extends JPanel {
 		gbc_levelBenefits.gridy = 1;
 		add(levelBenefits, gbc_levelBenefits);
 		
-		if (model.getSpellBook().isPresent()) {
+		model.getSpellBook().ifPresent(wrap -> {
 			SpellPanel spells = new SpellPanel(chara, model);
 			GridBagConstraints gbc_spells = new GridBagConstraints();
 			gbc_spells.fill = GridBagConstraints.BOTH;
 			gbc_spells.gridx = 0;
 			gbc_spells.gridy = 2;
 			add(spells, gbc_spells);
-		}
+		});
 	}
 	
 	@Override

+ 14 - 8
src/main/lombok/org/leumasjaffe/charsheet/view/D20Sheet.java

@@ -24,6 +24,7 @@ import javax.swing.UIManager;
 import org.leumasjaffe.charsheet.config.Config;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.observer.ObserverHelper;
 import org.leumasjaffe.charsheet.view.level.ExperienceDialog;
 import org.leumasjaffe.observer.ObservableListener;
@@ -141,14 +142,9 @@ public class D20Sheet extends JFrame {
 			// Step 2: Regenerate spells prepared
 			// Step 2.1: If Spontaneous, skip (2)
 			for (DDCharacterClass dclass : model.getClasses()) {
-				dclass.getSpellBook().ifPresent(sb -> {
-					if (sb.preparesSpells()) {
-						DialogBuilder.createPrepareSpellsDialog(this, model, dclass);
-					} else {
-						for (int i = 0; i < dclass.getHighestSpellLevel(); ++i) {
-							sb.prepareSpells(i, null);
-						}
-					}
+				dclass.getSpellBook().ifPresent(wrap -> {
+					runPrepareSpells(dclass, wrap.getMain());
+					wrap.getSecondary().ifPresent(sb -> runPrepareSpells(dclass, sb));
 				});
 			}
 			// Step N: regenerate spellbooks
@@ -166,6 +162,16 @@ public class D20Sheet extends JFrame {
 		setModel(model);
 	}
 
+	private void runPrepareSpells(DDCharacterClass dclass, DDSpellbook sb) {
+		if (sb.preparesSpells()) {
+			DialogBuilder.createPrepareSpellsDialog(this, model, dclass, sb);
+		} else {
+			for (int i = 0; i < dclass.getHighestSpellLevel(); ++i) {
+				sb.prepareSpells(i, null);
+			}
+		}
+	}
+
 	public D20Sheet(final String initialFile) {
 		this();
 		loadModelResource(new File(initialFile));

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

@@ -8,6 +8,7 @@ import javax.swing.JPanel;
 
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.view.config.ConfigPanel;
 import org.leumasjaffe.charsheet.view.level.ExperienceDialog;
 import org.leumasjaffe.charsheet.view.level.LevelUpDialog;
@@ -33,8 +34,8 @@ public class DialogBuilder {
 		createDialog(parent, "Level Up - Skill Allocation", new SkillLevelUpDialog(chara, dclass));
 	}
 	
-	public void createPrepareSpellsDialog(final JFrame parent, DDCharacter chara, DDCharacterClass dclass) {
-		createDialog(parent, "Prepare Spells - " + dclass.getName(), new PrepareSpellsDialog(chara, dclass));
+	public void createPrepareSpellsDialog(final JFrame parent, DDCharacter chara, DDCharacterClass dclass, DDSpellbook spellBook) {
+		createDialog(parent, "Prepare Spells - " + dclass.getName(), new PrepareSpellsDialog(chara, dclass, spellBook));
 	}
 	
 	public void showConfigDialog(final JFrame parent) {

+ 17 - 45
src/main/lombok/org/leumasjaffe/charsheet/view/level/LevelUpSpellPanel.java

@@ -3,11 +3,12 @@ package org.leumasjaffe.charsheet.view.level;
 import javax.swing.JPanel;
 
 import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.charsheet.controller.magic.ChooseSpellTuple;
+import org.leumasjaffe.charsheet.controller.magic.SpellPicker;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.observable.BoolGate;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
 import org.leumasjaffe.charsheet.view.magic.SelectSpellsPanel;
-import org.leumasjaffe.charsheet.view.magic.SelectSpellsPanel.Info;
 import org.leumasjaffe.observer.ObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 
@@ -19,11 +20,11 @@ import lombok.experimental.NonFinal;
 import java.awt.GridBagLayout;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 import javax.swing.JScrollPane;
 import java.awt.GridBagConstraints;
@@ -31,45 +32,15 @@ import java.awt.GridBagConstraints;
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PUBLIC, makeFinal=true)
 class LevelUpSpellPanel extends JPanel {
-	public static interface SpellPicker {
-		public List<List<Integer>> getSpellCounts(SelectSpellsPanel.Info info);
-		public Collection<DDSpell> getAvailableSpells(SelectSpellsPanel.Info info, int i);
-	}
-	public static enum SpellPickType implements SpellPicker {
-		LEARN {
-			@Override
-			public List<List<Integer>> getSpellCounts(SelectSpellsPanel.Info info) {
-				return info.dclass.getProto().getSpells().get().getKnown();
-			}
-			@Override
-			public Collection<DDSpell> getAvailableSpells(Info info, int i) {
-				Collection<DDSpell> spells = new ArrayList<>(info.dclass.getProto().getSpellList(i));
-				spells.removeAll(info.dclass.getSpellBook().get().spellsKnownAtLevel(i));
-				return spells;
-			}
-
-		}, PREPARE {
-			@Override
-			public List<List<Integer>> getSpellCounts(SelectSpellsPanel.Info info) {
-				// TODO: Bonus spells for high ability scores
-				return info.dclass.getProto().getSpells().get().getPerDay();
-			}
-			@Override
-			public Collection<DDSpell> getAvailableSpells(Info info, int i) {
-				return info.dclass.getSpellBook().get().spellsKnownAtLevel(i);
-			}
-		};		
-	}
-
 	int[] ready = {0};
 	@NonFinal int spellLevelsGrown = 0;
 	int oldHighestSpellLevel, newHighestSpellLevel, toLevel;
 	SpellPicker pick;
-	SelectSpellsPanel.Info info;
+	ChooseSpellTuple info;
 	@Getter List<SelectSpellsPanel> panels;
 	ObservableListener<Void, BoolGate.Meta> allReady;
 
-	public LevelUpSpellPanel(SpellPicker pick, SelectSpellsPanel.Info info, 
+	public LevelUpSpellPanel(SpellPicker pick, ChooseSpellTuple info, 
 			BoolGate.Handle readyCount) {
 		this.pick = pick;
 		this.info = info;
@@ -99,26 +70,27 @@ class LevelUpSpellPanel extends JPanel {
 		final IntValue val = new IntValue(getSharedAllowedSlots(info));
 		final Map<Integer, Integer> spells = getNewSpells(val);
 		final int sharedSlots = val.value();
+				
 		BoolGate.Meta gate = readyCount.createSubGate(newHighestSpellLevel);
 		for (int i = 0; i < newHighestSpellLevel; ++i) {
 			if (spells.get(i) < 0) { gate.set(i, true); panels.add(null); continue; }
 			++spellLevelsGrown;
-			SelectSpellsPanel lvl = new SelectSpellsPanel(info, gate.handle(i), i,
-					new LinkedHashSet<>(), Math.max(spells.get(i), sharedSlots), 
-					pick.getAvailableSpells(info, i),
-					pick != SpellPickType.LEARN, val);
+			SelectSpellsPanel lvl = new SelectSpellsPanel(pick, gate.handle(i), i,
+					createArray(Math.max(spells.get(i), sharedSlots)), val);
 			panels.add(lvl);
 			panel.add(lvl);
 		}
 		allReady = gate.makeListener();
 	}
 	
+	private Collection<DDSpell> createArray(int max) {
+		return IntStream.range(0, max).<DDSpell>mapToObj(i -> null).collect(Collectors.toList());
+	}
+
 	private Map<Integer, Integer> getNewSpells(IntValue sharedSpellCountLimit) {
 		final Map<Integer, Integer> map = new TreeMap<>();
-		final List<List<Integer>> spellList = pick.getSpellCounts(info);
-		final List<Integer> spellsAtPreviousLevel = toLevel == 1 ? Collections.emptyList() :
-			spellList.get(toLevel-2);
-		final List<Integer> spellsAtCurrentLevel = spellList.get(toLevel-1);
+		final List<Integer> spellsAtPreviousLevel = pick.getSpellCounts(toLevel-1);
+		final List<Integer> spellsAtCurrentLevel = pick.getSpellCounts(toLevel);
 		for (int i = 0; i < newHighestSpellLevel; ++i) {
 			map.put(i, diff(spellsAtCurrentLevel, spellsAtPreviousLevel, i,
 					isNewSpellCircle(i)));
@@ -127,8 +99,8 @@ class LevelUpSpellPanel extends JPanel {
 		return map;
 	}
 
-	private int getSharedAllowedSlots(Info info) {
-		return info.dclass.getSpellBook().get().getSharedAllowedSlots();
+	private int getSharedAllowedSlots(ChooseSpellTuple info) {
+		return info.spellBook.getSharedAllowedSlots();
 	}
 
 	private boolean isNewSpellCircle(int i) {

+ 74 - 60
src/main/lombok/org/leumasjaffe/charsheet/view/level/UpdateClassWithLevelPanel.java

@@ -1,9 +1,7 @@
 package org.leumasjaffe.charsheet.view.level;
 
-import static org.leumasjaffe.charsheet.view.level.LevelUpSpellPanel.SpellPickType.*;
-
 import java.util.ArrayList;
-import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import java.util.SortedSet;
@@ -12,13 +10,19 @@ import java.util.function.Consumer;
 
 import javax.swing.JPanel;
 
+import org.leumasjaffe.charsheet.controller.magic.ChooseSpellTuple;
+import org.leumasjaffe.charsheet.controller.magic.LearnSpellPicker;
+import org.leumasjaffe.charsheet.controller.magic.PrepareDomainSpellPicker;
+import org.leumasjaffe.charsheet.controller.magic.PrepareNewlyLearnedSpellPicker;
+import org.leumasjaffe.charsheet.controller.magic.PrepareSpellPicker;
+import org.leumasjaffe.charsheet.controller.magic.SpellPicker;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.DDCharacterClass.DDSpellbookWrapper;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.model.observable.BoolGate;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
 import org.leumasjaffe.charsheet.view.magic.SelectSpellsPanel;
-import org.leumasjaffe.charsheet.view.magic.SelectSpellsPanel.Info;
 import org.leumasjaffe.charsheet.view.skills.SkillLevelUpPanel;
 import org.leumasjaffe.function.VoidVoidFunction;
 import org.leumasjaffe.observer.ObservableListener;
@@ -33,6 +37,7 @@ import javax.swing.JTabbedPane;
 
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
+
 import java.awt.Component;
 import javax.swing.Box;
 import javax.swing.JButton;
@@ -40,7 +45,8 @@ import javax.swing.JButton;
 @SuppressWarnings("serial")
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 class UpdateClassWithLevelPanel extends JPanel {
-	static int FEATURE_INDEX = 0, CHOOSE_SKILL_INDEX = 1, LEARN_SPELL_INDEX = 2, PREPARE_SPELL_INDEX = 3;
+	static int FEATURE_INDEX = 0, CHOOSE_SKILL_INDEX = 1, LEARN_SPELL_INDEX = 2, PREPARE_SPELL_INDEX = 3,
+			PREPARE_BONUS_SPELL_INDEX = 4;
 
 	LevelUpClassInfo levelUpInfo;
 	JTabbedPane tabbedPane;
@@ -48,15 +54,20 @@ class UpdateClassWithLevelPanel extends JPanel {
 	SkillLevelUpPanel skills;
 	@NonFinal Optional<LevelUpSpellPanel> learnSpells = Optional.empty();
 	@NonFinal Optional<LevelUpSpellPanel> prepSpells = Optional.empty();
+	@NonFinal Optional<LevelUpSpellPanel> prepBonusSpells = Optional.empty();
 	
-	BoolGate readyCount = new BoolGate(4);
+	BoolGate readyCount = new BoolGate(5);
 	ObservableListener<Consumer<Boolean>, BoolGate> listener;
-	@NonFinal ObservableListener<UpdateClassWithLevelPanel, BoolGate> learnAndPrepareListener = null;
+	@NonFinal ObservableListener<SpellPicker, SpellPicker> learnAndPrepareListener = null;
 
 	LU_FeaturesPanel featurePanel;
+
 	
 	public UpdateClassWithLevelPanel(LevelUpClassInfo info, VoidVoidFunction back,
 			Consumer<Boolean> setReady) {
+		readyCount.set(LEARN_SPELL_INDEX, true);
+		readyCount.set(PREPARE_SPELL_INDEX, true);
+		readyCount.set(PREPARE_BONUS_SPELL_INDEX, true);
 		this.levelUpInfo = info;
 		info.ddClass.getLevel().value(info.toLevel);
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -85,15 +96,7 @@ class UpdateClassWithLevelPanel extends JPanel {
 		};
 		tabbedPane.addTab("Skills", null, skills, null);
 
-		info.ddClass.getSpellBook().ifPresent(sb -> {
-			readyCount.set(LEARN_SPELL_INDEX, !sb.learnsSpells());
-			readyCount.set(PREPARE_SPELL_INDEX, !sb.preparesSpells());
-			if (sb.learnsSpells()) {
-				createPanelsForLearnSpell(sb);
-			} else if (sb.preparesSpells()) {
-				createPanelForPrepareSpells();
-			}
-		});
+		info.ddClass.getSpellBook().ifPresent(wrap -> initSpellTabs(info, wrap));
 
 		JPanel panel = new JPanel();
 		GridBagConstraints gbc_panel = new GridBagConstraints();
@@ -124,53 +127,56 @@ class UpdateClassWithLevelPanel extends JPanel {
 
 		listener = readyCount.makeListener(setReady);
 	}
+	
+	void initSpellTabs(LevelUpClassInfo info, DDSpellbookWrapper wrap) {
+		DDSpellbook sb = wrap.getMain();
+		readyCount.set(LEARN_SPELL_INDEX, !sb.learnsSpells());
+		readyCount.set(PREPARE_SPELL_INDEX, !sb.preparesSpells());
+		if (sb.learnsSpells()) {
+			createPanelsForLearnSpell(new ChooseSpellTuple(info.ddCharacter, info.ddClass, sb));
+		} else if (sb.preparesSpells()) {
+			createPanelForPrepareSpells(new ChooseSpellTuple(info.ddCharacter, info.ddClass, sb));
+		}
+		wrap.getSecondary().ifPresent(sec -> {
+			readyCount.set(PREPARE_BONUS_SPELL_INDEX, false);
+			createBonusSpellbookPreparePanel(new ChooseSpellTuple(info.ddCharacter, info.ddClass, sec));
+		});
+	}
 
-	private void createPanelForPrepareSpells() {
-		prepSpells = Optional.of(new LevelUpSpellPanel(PREPARE,
-				new SelectSpellsPanel.Info(levelUpInfo.ddCharacter, levelUpInfo.ddClass),
-				readyCount.handle(PREPARE_SPELL_INDEX)));
+	private void createPanelForPrepareSpells(ChooseSpellTuple info) {
+		prepSpells = Optional.of(new LevelUpSpellPanel(new PrepareSpellPicker(info), info, readyCount.handle(PREPARE_SPELL_INDEX)));
 		tabbedPane.addTab("Prepare Spells", null, prepSpells.get(), null);
 	}
 
-	private void createPanelsForLearnSpell(DDSpellbook sb) {
-		learnSpells = Optional.of(new LevelUpSpellPanel(LEARN,
-				new SelectSpellsPanel.Info(levelUpInfo.ddCharacter, levelUpInfo.ddClass),
-				readyCount.handle(LEARN_SPELL_INDEX)));
+	private void createPanelsForLearnSpell(ChooseSpellTuple info) {
+		SpellPicker pick = new LearnSpellPicker(info);
+		learnSpells = Optional.of(new LevelUpSpellPanel(pick, info, readyCount.handle(LEARN_SPELL_INDEX)));
 		tabbedPane.addTab("Learn Spells", null, learnSpells.get(), null);
-		if (sb.preparesSpells()) {
-			learnAndPrepareListener = new ObservableListener<>(this, (c, v) -> {
-				if (v.get(LEARN_SPELL_INDEX)) {
-					if (!prepSpells.isPresent()) c.createPrepareLearnedSpellPanel(learnSpells.get());
-				} else {
-					prepSpells.ifPresent(c.tabbedPane::remove);
-					c.prepSpells = Optional.empty();
-				}
-			});
-			learnAndPrepareListener.setObserved(readyCount);
+		if (info.spellBook.preparesSpells()) {
+			createPrepareLearnedSpellPanel(learnSpells.get(), info, pick);
 		}
 	}
+	
+	private void createBonusSpellbookPreparePanel(ChooseSpellTuple info) {
+		prepBonusSpells = Optional.of(new LevelUpSpellPanel(new PrepareDomainSpellPicker(info),
+				new ChooseSpellTuple(levelUpInfo.ddCharacter, levelUpInfo.ddClass, info.spellBook),
+				readyCount.handle(PREPARE_BONUS_SPELL_INDEX)));
+		tabbedPane.addTab("Bonus Spells", null, prepBonusSpells.get(), null);
+	}
 
-	private void createPrepareLearnedSpellPanel(LevelUpSpellPanel spells) {
-		LevelUpSpellPanel.SpellPicker pick = new LevelUpSpellPanel.SpellPicker() {
-			@Override
-			public List<List<Integer>> getSpellCounts(Info info) {
-				return PREPARE.getSpellCounts(info);
-			}
-
-			@Override
-			public Collection<DDSpell> getAvailableSpells(Info info, int i) {
-				final Collection<DDSpell> start = new ArrayList<>(PREPARE.getAvailableSpells(info, i));
-				if (spells.getPanels().get(i) != null) {
-					start.addAll(spells.getPanels().get(i).getPrepared());
-				}
-				return start;
-			}
-		};
-
+	private void createPrepareLearnedSpellPanel(LevelUpSpellPanel spells, ChooseSpellTuple info, SpellPicker learn) {
+		SpellPicker pick = new PrepareNewlyLearnedSpellPicker(info, level -> 
+			Optional.ofNullable(spells.getPanels().get(level))
+			.map(SelectSpellsPanel::getPrepared)
+			.orElse(Collections.emptyList())
+		);
+		
 		prepSpells = Optional.of(new LevelUpSpellPanel(pick,
-				new SelectSpellsPanel.Info(levelUpInfo.ddCharacter, levelUpInfo.ddClass),
+				new ChooseSpellTuple(levelUpInfo.ddCharacter, levelUpInfo.ddClass, info.spellBook),
 				readyCount.handle(PREPARE_SPELL_INDEX)));
 		tabbedPane.addTab("Prepare Spells", null, prepSpells.get(), null);
+		
+		learnAndPrepareListener = ObservableListener.cascade(learn, pick);
 	}
 	
 	@Override
@@ -181,23 +187,32 @@ class UpdateClassWithLevelPanel extends JPanel {
 	}
 
 	
-	private void commitSpellbook(DDSpellbook book) {
+	private void commitSpellbook(DDSpellbookWrapper wrap) {
 		learnSpells.ifPresent(pan -> {
 			final List<SelectSpellsPanel> selections = pan.getPanels();
 			for (int i = 0; i < selections.size(); ++i) {
 				if (selections.get(i) == null) continue;
-				List<DDSpell> known = new ArrayList<>(book.spellsKnownAtLevel(i));
+				List<DDSpell> known = new ArrayList<>(wrap.getMain().spellsKnownAtLevel(i));
 				known.addAll(selections.get(i).getPrepared());
-				book.learnSpells(i, known);
+				wrap.getMain().learnSpells(i, known);
 			}
 		});
 		prepSpells.ifPresent(pan -> {
 			final List<SelectSpellsPanel> selections = pan.getPanels();
 			for (int i = 0; i < selections.size(); ++i) {
 				if (selections.get(i) == null) continue;
-				List<DDSpell> known = new ArrayList<>(book.spellsPreparedAtLevel(i));
-				known.addAll(selections.get(i).getPrepared());
-				book.prepareSpells(i, known);
+				List<DDSpell> prep = new ArrayList<>(wrap.getMain().spellsPreparedAtLevel(i));
+				prep.addAll(selections.get(i).getPrepared());
+				wrap.getMain().prepareSpells(i, prep);
+			}
+		});
+		prepBonusSpells.ifPresent(pan -> {
+			final List<SelectSpellsPanel> selections = pan.getPanels();
+			for (int i = 0; i < selections.size(); ++i) {
+				if (selections.get(i) == null) continue;
+				List<DDSpell> prep = new ArrayList<>(wrap.getSecondary().get().spellsPreparedAtLevel(i));
+				prep.addAll(selections.get(i).getPrepared());
+				wrap.getSecondary().get().prepareSpells(i, prep);
 			}
 		});
 	}
@@ -205,8 +220,7 @@ class UpdateClassWithLevelPanel extends JPanel {
 	public void commitAllChanges() {
 		final String className = levelUpInfo.ddClass.getName();
 		skills.commitAllChanges();
-		final Optional<DDSpellbook> maybeBook = levelUpInfo.ddClass.getSpellBook();
-		maybeBook.ifPresent(this::commitSpellbook);
+		levelUpInfo.ddClass.getSpellBook().ifPresent(this::commitSpellbook);
 		final SortedSet<DDCharacterClass> classes = new TreeSet<>(levelUpInfo.ddCharacter.getClasses());
 		classes.removeIf(cc -> cc.getName().equals(className));
 		classes.add(levelUpInfo.ddClass);

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

@@ -3,8 +3,12 @@ package org.leumasjaffe.charsheet.view.magic;
 import javax.swing.JPanel;
 
 import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.charsheet.controller.magic.ChooseSpellTuple;
+import org.leumasjaffe.charsheet.controller.magic.PrepareSpellPicker;
+import org.leumasjaffe.charsheet.controller.magic.SpellPicker;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.model.magic.impl.Prepared;
 import org.leumasjaffe.charsheet.model.observable.BoolGate;
 import org.leumasjaffe.observer.ObservableListener;
@@ -33,7 +37,7 @@ public class PrepareSpellsDialog extends JPanel {
 	int highestSpellLevel;
 	ObservableListener<Consumer<Boolean>, BoolGate> allReady;
 
-	public PrepareSpellsDialog(DDCharacter chara, DDCharacterClass dclass) {
+	public PrepareSpellsDialog(DDCharacter chara, DDCharacterClass dclass, DDSpellbook spellBook) {
 		highestSpellLevel = dclass.getHighestSpellLevel();
 		ready[0] = highestSpellLevel;
 		
@@ -76,16 +80,18 @@ public class PrepareSpellsDialog extends JPanel {
 		JPanel panel = new JPanel(new VerticalLayout(5));
 		scrollPane.setViewportView(panel);
 		
-		List<SelectPreparedSpellsPanel> panels = new ArrayList<>();
+		List<SelectSpellsPanel> panels = new ArrayList<>();
 		final BoolGate gate = new BoolGate(highestSpellLevel);
 		allReady = gate.makeListener(btnPrepareTheseSpells::setEnabled);
+		ChooseSpellTuple tup = new ChooseSpellTuple(chara, dclass, spellBook);
+		SpellPicker pick = new PrepareSpellPicker(tup);
 		for (int i = 0; i < highestSpellLevel; ++i) {
-			SelectPreparedSpellsPanel lvl = new SelectPreparedSpellsPanel(chara, dclass, gate.handle(i), i);
+			SelectSpellsPanel lvl = new SelectSpellsPanel(pick, gate.handle(i), i,
+					((Prepared) spellBook).getSpellsPreparedPreviouslyForLevel(i), null);
 			panels.add(lvl);
 			panel.add(lvl);
 		}
 		
-		final Prepared spellBook = (Prepared) dclass.getSpellBook().get();
 		btnPrepareTheseSpells.addActionListener(e -> {
 			for (int i = 0; i < highestSpellLevel; ++i) {
 				spellBook.prepareSpells(i, panels.get(i).getPrepared());

+ 0 - 26
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SelectPreparedSpellsPanel.java

@@ -1,26 +0,0 @@
-package org.leumasjaffe.charsheet.view.magic;
-
-import org.leumasjaffe.charsheet.model.DDCharacter;
-import org.leumasjaffe.charsheet.model.DDCharacterClass;
-import org.leumasjaffe.charsheet.model.magic.impl.Prepared;
-import org.leumasjaffe.charsheet.model.observable.BoolGate;
-
-import lombok.AccessLevel;
-import lombok.experimental.FieldDefaults;
-
-@SuppressWarnings("serial")
-@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-class SelectPreparedSpellsPanel extends SelectSpellsPanel {
-	
-	private SelectPreparedSpellsPanel(DDCharacter chara, DDCharacterClass dclass, 
-			BoolGate.Handle gate, int level, Prepared prep) {
-		super(new SelectSpellsPanel.Info(chara, dclass), gate, level,
-				prep.getSpellsPreparedPreviouslyForLevel(level),
-				prep.spellsKnownAtLevel(level));
-	}
-
-	public SelectPreparedSpellsPanel(DDCharacter chara, DDCharacterClass dclass, BoolGate.Handle gate, int level) {
-		this(chara, dclass, gate, level, ((Prepared) dclass.getSpellBook().get()));
-	}
-
-}

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

@@ -10,15 +10,14 @@ import javax.swing.JPopupMenu;
 import javax.swing.JTable;
 import javax.swing.ListSelectionModel;
 
-import org.leumasjaffe.charsheet.model.DDCharacter;
-import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.controller.magic.SpellPicker;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
-import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.charsheet.model.observable.BoolGate;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
-import org.leumasjaffe.charsheet.util.AbilityHelper;
 import org.leumasjaffe.event.SelectTableRowPopupMenuListener;
 import org.leumasjaffe.format.StringHelper;
+import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
@@ -42,7 +41,7 @@ import javax.swing.JScrollPane;
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 public class SelectSpellsPanel extends JPanel {
 	
-	private static final String NONE = "<none>";
+	static String NONE = "<none>";
 
 	@AllArgsConstructor
 	private static class SelectSpellModel extends AbstractTableModel {
@@ -51,7 +50,7 @@ public class SelectSpellsPanel extends JPanel {
 		 */
 		private static final long serialVersionUID = 1L;
 		
-		final Object[] data;
+		Object[] data;
 
 		@Override
 		public int getRowCount() {
@@ -76,31 +75,29 @@ public class SelectSpellsPanel extends JPanel {
 		}
 	}
 
-	@AllArgsConstructor
-	@FieldDefaults(level=AccessLevel.PUBLIC, makeFinal=true)
-	public static class Info {
-		DDCharacter chara;
-		DDCharacterClass dclass;
-	}
-	
 	public static final String READY = "Is Filled Out";
 	
 	@Getter Collection<DDSpell> prepared;
-	boolean allowsDuplicates;
+	List<DDSpell> known = new ArrayList<>();
+	SpellPicker pick;
 	IntValue sharedValue;
 	SelectSpellModel modelPrepared, modelKnown;
+
+	BoolGate.Handle gate;
+	JTable tablePrepared, tableKnown;
 	
-	public SelectSpellsPanel(Info info, BoolGate.Handle gate, int level, Collection<DDSpell> prepared, int toPrepare,
-			Collection<DDSpell> avail, boolean allowsDuplicates, IntValue sharedValue) {
-		this.allowsDuplicates = allowsDuplicates;
-		this.sharedValue = sharedValue;
-		final DDSpellbook spellBook = info.dclass.getSpellBook().get();
+	ObservableListener<JTable, SpellPicker> listener;
+	
+	public SelectSpellsPanel(SpellPicker pick, BoolGate.Handle gate, int level, 
+			Collection<DDSpell> prepared, IntValue sharedValue) {
+		this.pick = pick;
+		this.gate = gate;
+		this.sharedValue = sharedValue == null ? new IntValue(-1) : sharedValue;
 		this.prepared = new ArrayList<>(prepared);
-		final List<DDSpell> known = new ArrayList<>(avail);
-		this.modelPrepared = new SelectSpellModel(createPrepareModel(prepared, toPrepare));
-		this.modelKnown = new SelectSpellModel(known.stream().map(DDSpell::getName).toArray());
-		putClientProperty(READY, countNone() == 0);
-		sharedValue.value(sharedValue.value() - this.modelPrepared.data.length + countNone());
+		this.modelPrepared = new SelectSpellModel(createModel(prepared));
+		this.modelKnown = new SelectSpellModel(null);
+		gate.set(countNone() == 0);
+		this.sharedValue.value(this.sharedValue.value() - this.modelPrepared.data.length + countNone());
 		
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 40, 0, 0};
@@ -109,7 +106,7 @@ public class SelectSpellsPanel extends JPanel {
 		gridBagLayout.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
-		JPanel panel = new ChooseSpellsPerDayHeader(level, spellBook, AbilityHelper.get(info.chara, info.dclass));
+		JPanel panel = new ChooseSpellsPerDayHeader(level, pick.getInfo().spellBook, pick.getInfo().ability());
 		GridBagConstraints gbc_panel = new GridBagConstraints();
 		gbc_panel.gridwidth = 3;
 		gbc_panel.insets = new Insets(0, 0, 5, 5);
@@ -128,7 +125,7 @@ public class SelectSpellsPanel extends JPanel {
 		gbc_scrollPane_1.gridy = 1;
 		add(scrollPane_1, gbc_scrollPane_1);
 		
-		JTable tablePrepared = new JTable(modelPrepared);
+		tablePrepared = new JTable(modelPrepared);
 		tablePrepared.setTableHeader(null);
 		scrollPane_1.setViewportView(tablePrepared);
 		tablePrepared.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
@@ -157,7 +154,7 @@ public class SelectSpellsPanel extends JPanel {
 		gbc_scrollPane.gridy = 1;
 		add(scrollPane, gbc_scrollPane);
 		
-		JTable tableKnown = new JTable(modelKnown);
+		tableKnown = new JTable(modelKnown);
 		tableKnown.setTableHeader(null);
 		scrollPane.setViewportView(tableKnown);
 		tableKnown.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
@@ -167,8 +164,8 @@ public class SelectSpellsPanel extends JPanel {
 		JMenuItem mntmInfo = new JMenuItem("Info");
 		mntmInfo.addActionListener( e -> {
 			DDSpell spell = known.get(tableKnown.getSelectedRow());
-			JFrame frame = new JFrame(spell.getName() +  " (" + info.dclass.getName() + " " + level + ")");
-			frame.add(new SpellInfoPanel(info.chara, info.dclass, spell));
+			JFrame frame = new JFrame(spell.getName() +  " (" + pick.getInfo().dclass.getName() + " " + level + ")");
+			frame.add(new SpellInfoPanel(pick.getInfo().chara, pick.getInfo().dclass, spell));
 			frame.pack();
 			frame.setVisible(true);
 		});
@@ -182,18 +179,7 @@ public class SelectSpellsPanel extends JPanel {
 		gbc_button.gridx = 0;
 		gbc_button.gridy = 1;
 		panelDivider.add(button, gbc_button);
-		button.addActionListener(e -> {
-			final int row = tablePrepared.getSelectedRow();
-			if (row != -1 && !modelPrepared.data[row].equals(NONE)) {
-				sharedValue.value(sharedValue.value() + 1);
-				modelPrepared.setValueAt(NONE, row, 0);
-			}
-			tablePrepared.getSelectionModel().clearSelection();
-			tablePrepared.repaint();
-			if ((Boolean) getClientProperty(READY)) {
-				putClientProperty(READY, false);
-			}
-		});
+		button.addActionListener(e -> removeSpell());
 		
 		JButton button_1 = new JButton("<<");
 		button_1.setMargin(new Insets(2, 8, 2, 8));
@@ -203,63 +189,65 @@ public class SelectSpellsPanel extends JPanel {
 		gbc_button_1.gridy = 2;
 		panelDivider.add(button_1, gbc_button_1);
 		
-		button_1.addActionListener(e -> {
-			final int[] rows = tableKnown.getSelectedRows();
-			final int[] orows = tablePrepared.getSelectedRows();
-			if (sharedValue.value() == 0) {
-				JOptionPane.showMessageDialog(this, "You have exceeded the shared limit on new spells", 
-						"Error", JOptionPane.ERROR_MESSAGE);
-			} else if (orows.length >= rows.length) {
-				for (int i = 0; i < rows.length; ++i) {
-					if (wouldHaveIllegalDuplicate(rows[i])) continue;
-					modelPrepared.data[orows[i]] = modelKnown.data[rows[i]];
-					sharedValue.value(sharedValue.value() - 1);
-				}
-			} else if (orows.length == 0 && countNone() >= rows.length) {
-				replace(rows);
-			} else {
-				final String message = StringHelper.format(
-						"Unable to assign new spells, more spells were selected ({}) than were avaliable ({})", 
-						rows.length, orows.length == 0 ? countNone() : orows.length);
-				JOptionPane.showMessageDialog(this, message, "Error", JOptionPane.ERROR_MESSAGE);
-			}
-			tableKnown.getSelectionModel().clearSelection();
-			tablePrepared.getSelectionModel().clearSelection();
-			tablePrepared.repaint();
-			
-			if (!gate.get() && !Arrays.asList(modelPrepared.data).contains(NONE)) {
-				this.prepared.clear();
-				for (Object o : modelPrepared.data) {
-					this.prepared.add(DDSpell.fromString((String) o)); // TODO
-				}
-				gate.set(true);
-			}
+		button_1.addActionListener(e -> insertSpell());
+		
+		listener = new ObservableListener<>(tableKnown, (c, v) -> {
+			known.clear();
+			known.addAll(v.getAvailableSpells(level));
+			this.modelKnown.data = createModel(known);
 		});
+		listener.setObserved(pick);
+	}
+	
+	private void removeSpell() {
+		final int row = tablePrepared.getSelectedRow();
+		if (row != -1 && !modelPrepared.data[row].equals(NONE)) {
+			this.sharedValue.value(this.sharedValue.value() + 1);
+			modelPrepared.setValueAt(NONE, row, 0);
+		}
+		tablePrepared.getSelectionModel().clearSelection();
+		tablePrepared.repaint();
+		this.gate.set(false);
 	}
 
-	public SelectSpellsPanel(Info info, BoolGate.Handle gate, int level,
-			Collection<DDSpell> prepared, Collection<DDSpell> avail) {
-		this(info, gate, level, prepared, 0, avail, true, new IntValue(-1));
+	private void insertSpell() {
+		final int[] rows = tableKnown.getSelectedRows();
+		final int[] orows = tablePrepared.getSelectedRows();
+		if (this.sharedValue.value() == 0) {
+			JOptionPane.showMessageDialog(this, "You have exceeded the shared limit on new spells", 
+					"Error", JOptionPane.ERROR_MESSAGE);
+		} else if (orows.length >= rows.length) {
+			for (int i = 0; i < rows.length; ++i) {
+				if (wouldHaveIllegalDuplicate(rows[i])) continue;
+				modelPrepared.data[orows[i]] = modelKnown.data[rows[i]];
+				this.sharedValue.value(this.sharedValue.value() - 1);
+			}
+		} else if (orows.length == 0 && countNone() >= rows.length) {
+			replace(rows);
+		} else {
+			final String message = StringHelper.format(
+					"Unable to assign new spells, more spells were selected ({}) than were avaliable ({})", 
+					rows.length, orows.length == 0 ? countNone() : orows.length);
+			JOptionPane.showMessageDialog(this, message, "Error", JOptionPane.ERROR_MESSAGE);
+		}
+		tableKnown.getSelectionModel().clearSelection();
+		tablePrepared.getSelectionModel().clearSelection();
+		tablePrepared.repaint();
+		
+		this.prepared.clear();
+		for (Object o : modelPrepared.data) {
+			this.prepared.add(DDSpell.fromString((String) o)); // TODO
+		}
+		gate.set(countNone() == 0);
+		ObserverDispatch.notifySubscribers(pick);
 	}
 
 	private boolean wouldHaveIllegalDuplicate(int row) {
-		return !this.allowsDuplicates && Arrays.asList(modelPrepared.data).contains(modelKnown.data[row]);
+		return !pick.allowsDuplicates() && Arrays.asList(modelPrepared.data).contains(modelKnown.data[row]);
 	}
 
-	private String[] createPrepareModel(Collection<DDSpell> prepared, int toPrepare) {
-		if (toPrepare <= prepared.size()) {
-			return prepared.stream().map(DDSpell::getName).toArray(String[]::new);
-		} else {
-			String[] data = new String[toPrepare];
-			int i = 0;
-			for (DDSpell sp : prepared) {
-				data[i++] = sp.getName();
-			}
-			for (; i < toPrepare; ++i) {
-				data[i] = NONE;
-			}
-			return data;
-		}
+	private String[] createModel(Collection<DDSpell> prepared) {
+		return prepared.stream().map(s -> s == null ? NONE : s.getName()).toArray(String[]::new);
 	}
 
 	private void replace(int[] rows) {
@@ -281,4 +269,10 @@ public class SelectSpellsPanel extends JPanel {
 		}
 		return cnt;
 	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
 }

+ 2 - 1
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java

@@ -220,7 +220,8 @@ class SpellInfoPanel extends JPanel {
 		gbc_lblTarget.gridy = 4;
 		panel.add(lblTarget, gbc_lblTarget);
 		
-		JTextField target = new JTextField(asString(spell.getTarget().getResolved(classLevel.value())));
+		JTextField target = new JTextField(spell.getTarget() == null ? "" :
+			asString(spell.getTarget().getResolved(classLevel.value())));
 		target.setEditable(false);
 		target.setColumns(10);
 		GridBagConstraints gbc_target = new GridBagConstraints();

+ 24 - 6
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellLevelPanel.java

@@ -1,6 +1,9 @@
 package org.leumasjaffe.charsheet.view.magic;
 
 import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.SwingConstants;
+
 import java.awt.GridBagLayout;
 import java.awt.Component;
 import javax.swing.Box;
@@ -13,6 +16,7 @@ import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.charsheet.model.Ability;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.DDCharacterClass.DDSpellbookWrapper;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.observer.IndirectObservableListener;
@@ -31,9 +35,7 @@ class SpellLevelPanel extends JPanel {
 	IndirectObservableListener<JPanel, DDCharacterClass> listener;
 
 	protected SpellLevelPanel(JPanel header, DDCharacter chara, DDCharacterClass dclass, int level, 
-			BiFunction<DDSpellbook, Integer, Collection<DDSpell>> getSpells) {		
-		final Collection<DDSpell> spells = getSpells.apply(dclass.getSpellBook().get(), level);
-
+			BiFunction<DDSpellbook, Integer, Collection<DDSpell>> getSpells) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0, 0, 0};
@@ -64,16 +66,32 @@ class SpellLevelPanel extends JPanel {
 		gbc_horizontalStrut.gridy = 2;
 		add(horizontalStrut, gbc_horizontalStrut);
 		
+		final DDSpellbookWrapper wrap = dclass.getSpellBook().get();
 		listener = new IndirectObservableListener<>(panel, (c, v) -> { 
 			c.removeAll();
-			spells.forEach(spell -> c.add(new SpellLine(chara, v, spell, isCastableFromHere())));
+			{
+				final DDSpellbook sb = wrap.getMain();
+				final Collection<DDSpell> spells = getSpells.apply(sb, level);
+				spells.forEach(spell -> c.add(new SpellLine(chara, v, sb, spell, isCastableFromHere())));
+			}
+			wrap.getSecondary().ifPresent(sb -> {
+				final Collection<DDSpell> spells = getSpells.apply(sb, level);
+				if (!spells.isEmpty()) c.add(new JSeparator(SwingConstants.HORIZONTAL));
+				spells.forEach(spell -> c.add(new SpellLine(chara, v, sb, spell, isCastableFromHere())));
+			});
 			c.repaint();
+			c.validate();
 		});
-		listener.setObserved(dclass, dclass.getSpellBook().get());
+		if (wrap.getSecondary().isPresent()) {
+			listener.setObserved(dclass, wrap.getMain(), wrap.getSecondary().get());			
+		} else {
+			listener.setObserved(dclass, wrap.getMain());			
+		}
 	}
 	
 	public SpellLevelPanel(DDCharacter chara, DDCharacterClass dclass, int level, Ability.Scores ability) {
-		this(new SpellsKnownHeader(level, dclass.getSpellBook().get(), ability), chara, dclass, level, DDSpellbook::spellsKnownAtLevel);
+		this(new SpellsKnownHeader(level, dclass.getSpellBook().get().getMain(), ability),
+				chara, dclass, level, DDSpellbook::spellsKnownAtLevel);
 	}
 	
 	public boolean isCastableFromHere() { return false; }

+ 2 - 1
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellLevelPerDayPanel.java

@@ -17,7 +17,8 @@ class SpellLevelPerDayPanel extends SpellLevelPanel {
 	private static final long serialVersionUID = 1L;
 	
 	public SpellLevelPerDayPanel(DDCharacter chara, DDCharacterClass dclass, int level, Ability.Scores ability) {
-		super(new SpellsPerDayHeader(level, dclass.getSpellBook().get(), ability), chara, dclass, level, DDSpellbook::spellsPreparedAtLevel);
+		super(new SpellsPerDayHeader(level, dclass.getSpellBook().get(), ability), 
+				chara, dclass, level, DDSpellbook::spellsPreparedAtLevel);
 	}
 	
 	public boolean isCastableFromHere() { return true; }

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

@@ -5,6 +5,7 @@ import javax.swing.JPanel;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.event.PopClickListener;
 
 import java.awt.GridBagLayout;
@@ -21,7 +22,8 @@ class SpellLine extends JPanel {
 	 */
 	private static final long serialVersionUID = 1L;
 
-	public SpellLine(DDCharacter chara, final DDCharacterClass dclass, final DDSpell spell, boolean isPrepared) {
+	public SpellLine(DDCharacter chara, final DDCharacterClass dclass, final DDSpellbook book,
+			final DDSpell spell, boolean isPrepared) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
@@ -102,7 +104,7 @@ class SpellLine extends JPanel {
 		gbc_lblAction_1.gridy = 0;
 		add(lblRange, gbc_lblAction_1);
 		
-		addMouseListener(new PopClickListener(new SpellMenu(chara, dclass, spell, isPrepared)));
+		addMouseListener(new PopClickListener(new SpellMenu(chara, dclass, book, spell, isPrepared)));
 	}
 
 }

+ 7 - 6
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellMenu.java

@@ -5,6 +5,7 @@ import javax.swing.JPopupMenu;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
 import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpell.SpellClassInfo;
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.observer.ObserverDispatch;
 
@@ -19,12 +20,13 @@ class SpellMenu extends JPopupMenu {
 	 */
 	private static final long serialVersionUID = 1L;
 
-	public SpellMenu(DDCharacter chara, final DDCharacterClass dclass, final DDSpell spell, boolean isPrepared) {
-		final int spellLevel = spell.getClassLevel(dclass.getName());
+	public SpellMenu(DDCharacter chara, final DDCharacterClass dclass, final DDSpellbook book,
+			final DDSpell spell, boolean isPrepared) {
+		final SpellClassInfo spellLevel = spell.getClassLevel(book.getSingleName());
 		
 		JMenuItem mntmInfo = new JMenuItem("Info");
 		mntmInfo.addActionListener( e -> {
-			JFrame frame = new JFrame(spell.getName() +  " (" + dclass.getName() + " " + spellLevel + ")");
+			JFrame frame = new JFrame(spell.getName() +  " (" + book.getName() + " " + spellLevel + ")");
 			frame.add(new SpellInfoPanel(chara, dclass, spell));
 			frame.pack();
 			frame.setVisible(true);
@@ -34,12 +36,11 @@ class SpellMenu extends JPopupMenu {
 		if (isPrepared) {
 			JMenuItem mntmCast = new JMenuItem("Cast");
 			mntmCast.addActionListener(e -> {
-				final DDSpellbook book = dclass.getSpellBook().get();
-				if (book.numSpellsPerDayRemainingAtLevel(spellLevel) == 0) {
+				if (book.numSpellsPerDayRemainingAtLevel(spellLevel.level) == 0) {
 					JOptionPane.showMessageDialog(this, "Cannot cast any more spells", "Error", JOptionPane.ERROR_MESSAGE);
 					return;
 				}
-				book.castSpell(spellLevel, spell);
+				book.castSpell(spellLevel.level, spell);
 				ObserverDispatch.notifySubscribers(book);
 			});
 			add(mntmCast);

+ 16 - 3
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellPanel.java

@@ -12,6 +12,7 @@ import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.charsheet.model.Ability;
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.DDCharacterClass.DDSpellbookWrapper;
 import org.leumasjaffe.charsheet.util.AbilityHelper;
 import org.leumasjaffe.function.QuadFunction;
 import org.leumasjaffe.observer.IndirectObservableListener;
@@ -61,10 +62,16 @@ public class SpellPanel extends JPanel {
 				
 		listenerPerDay = new IndirectObservableListener<>(prepared, 
 				new AppendSpellLevelOperation(chara, ability, SpellLevelPerDayPanel::new));
-		listenerPerDay.setObserved(dclass, ability, dclass.getLevel(), dclass.getSpellBook().get());
 		listenerKnown = new IndirectObservableListener<>(known, 
 				new AppendSpellLevelOperation(chara, ability, SpellLevelPanel::new));
-		listenerKnown.setObserved(dclass, ability, dclass.getLevel(), dclass.getSpellBook().get());
+		final DDSpellbookWrapper wrap = dclass.getSpellBook().get();
+		if (wrap.getSecondary().isPresent()) {
+			listenerPerDay.setObserved(dclass, ability, dclass.getLevel(), wrap.getMain(), wrap.getSecondary().get());
+			listenerKnown.setObserved(dclass, ability, dclass.getLevel(), wrap.getMain(), wrap.getSecondary().get());			
+		} else {
+			listenerPerDay.setObserved(dclass, ability, dclass.getLevel(), wrap.getMain());
+			listenerKnown.setObserved(dclass, ability, dclass.getLevel(), wrap.getMain());			
+		}
 	}
 
 	@RequiredArgsConstructor
@@ -78,11 +85,17 @@ public class SpellPanel extends JPanel {
 		@Override
 		public void accept(final JPanel root, final DDCharacterClass dclass) {		
 			for (int i = previousHighestSpellLevel; i < dclass.getHighestSpellLevel(); ++i) {
-				if (dclass.getSpellBook().get().numSpellsKnownAtLevel(i) == 0) break;
+				if (hasNoSpellsKnownAtLevel(dclass, i)) break;
 				root.add(function.apply(chara, dclass, i, ability));
 			}
 			previousHighestSpellLevel = dclass.getHighestSpellLevel();
 		}
+
+		private boolean hasNoSpellsKnownAtLevel(final DDCharacterClass dclass, final int i) {
+			final DDSpellbookWrapper wrap = dclass.getSpellBook().get();
+			return wrap.getMain().numSpellsKnownAtLevel(i) == 0 &&
+					wrap.getSecondary().map(sb -> sb.numSpellsKnownAtLevel(i) == 0).orElse(true);
+		}
 	}
 	
 	@Override

+ 5 - 1
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellsKnownHeader.java

@@ -13,6 +13,7 @@ import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
 import org.leumasjaffe.format.StringHelper;
 
 import java.awt.Dimension;
+import javax.swing.SwingConstants;
 
 class SpellsKnownHeader extends JPanel {
 	/**
@@ -23,7 +24,7 @@ class SpellsKnownHeader extends JPanel {
 	public SpellsKnownHeader(int level, DDSpellbook model, Ability.Scores ability) {
 		setPreferredSize(new Dimension(350, 20));
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 30, 0, 30, 0, 30, 0, 0};
+		gridBagLayout.columnWidths = new int[]{0, 30, 0, 35, 0, 35, 0, 0};
 		gridBagLayout.rowHeights = new int[]{0, 0};
 		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, Double.MIN_VALUE};
 		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
@@ -38,6 +39,7 @@ class SpellsKnownHeader extends JPanel {
 		add(lblSpellLevel, gbc_lblSpellLevel);
 		
 		JTextField textFieldLevel = new JTextField(Integer.toString(level));
+		textFieldLevel.setHorizontalAlignment(SwingConstants.CENTER);
 		textFieldLevel.setPreferredSize(new Dimension(30, 20));
 		textFieldLevel.setMaximumSize(new Dimension(30, 20));
 		textFieldLevel.setEditable(false);
@@ -58,6 +60,7 @@ class SpellsKnownHeader extends JPanel {
 		add(lblSaveDc, gbc_lblSaveDc);
 		
 		JTextField textFieldSpellSave = new JTextField(Integer.toString(10 + level + ability.modifier()));
+		textFieldSpellSave.setHorizontalAlignment(SwingConstants.CENTER);
 		textFieldSpellSave.setPreferredSize(new Dimension(30, 20));
 		textFieldSpellSave.setMaximumSize(new Dimension(30, 20));
 		textFieldSpellSave.setEditable(false);
@@ -77,6 +80,7 @@ class SpellsKnownHeader extends JPanel {
 		add(lblSpellsPerDay, gbc_lblSpellsPerDay);
 		
 		JTextField textFieldRemaining = new JTextField(StringHelper.toString(model.numSpellsKnownAtLevel(level), -1, "--"));
+		textFieldRemaining.setHorizontalAlignment(SwingConstants.CENTER);
 		textFieldRemaining.setPreferredSize(new Dimension(30, 20));
 		textFieldRemaining.setMaximumSize(new Dimension(30, 20));
 		GridBagConstraints gbc_textFieldRemaining = new GridBagConstraints();

+ 23 - 9
src/main/lombok/org/leumasjaffe/charsheet/view/magic/SpellsPerDayHeader.java

@@ -9,11 +9,12 @@ import javax.swing.JPanel;
 import javax.swing.JTextField;
 
 import org.leumasjaffe.charsheet.model.Ability;
-import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
-import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.charsheet.model.DDCharacterClass.DDSpellbookWrapper;
+import org.leumasjaffe.observer.IndirectObservableListener;
 import org.leumasjaffe.observer.ObserverDispatch;
 
 import java.awt.Dimension;
+import javax.swing.SwingConstants;
 
 class SpellsPerDayHeader extends JPanel {
 	/**
@@ -21,12 +22,12 @@ class SpellsPerDayHeader extends JPanel {
 	 */
 	private static final long serialVersionUID = 1L;
 
-	ObservableListener<JTextField, DDSpellbook> listener;
+	IndirectObservableListener<JTextField, DDSpellbookWrapper> listener;
 
-	public SpellsPerDayHeader(int level, DDSpellbook model, Ability.Scores ability) {
+	public SpellsPerDayHeader(int level, DDSpellbookWrapper model, Ability.Scores ability) {
 		setPreferredSize(new Dimension(350, 20));
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 30, 0, 30, 0, 30, 0, 30, 0, 0};
+		gridBagLayout.columnWidths = new int[]{0, 30, 0, 35, 0, 45, 0, 45, 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, 1.0, Double.MIN_VALUE};
 		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
@@ -41,6 +42,7 @@ class SpellsPerDayHeader extends JPanel {
 		add(lblSpellLevel, gbc_lblSpellLevel);
 		
 		JTextField textFieldLevel = new JTextField(Integer.toString(level));
+		textFieldLevel.setHorizontalAlignment(SwingConstants.CENTER);
 		textFieldLevel.setEditable(false);
 		GridBagConstraints gbc_textFieldLevel = new GridBagConstraints();
 		gbc_textFieldLevel.fill = GridBagConstraints.HORIZONTAL;
@@ -59,6 +61,7 @@ class SpellsPerDayHeader extends JPanel {
 		add(lblSaveDc, gbc_lblSaveDc);
 		
 		JTextField textFieldSpellSave = new JTextField(Integer.toString(10 + level + ability.modifier()));
+		textFieldSpellSave.setHorizontalAlignment(SwingConstants.CENTER);
 		textFieldSpellSave.setEditable(false);
 		textFieldSpellSave.setColumns(10);
 		GridBagConstraints gbc_textFieldSpellSave = new GridBagConstraints();
@@ -76,6 +79,7 @@ class SpellsPerDayHeader extends JPanel {
 		add(lblSpellsPerDay, gbc_lblSpellsPerDay);
 		
 		JTextField textFieldRemaining = new JTextField();
+		textFieldRemaining.setHorizontalAlignment(SwingConstants.CENTER);
 		GridBagConstraints gbc_textFieldRemaining = new GridBagConstraints();
 		gbc_textFieldRemaining.fill = GridBagConstraints.HORIZONTAL;
 		gbc_textFieldRemaining.insets = new Insets(0, 0, 0, 5);
@@ -92,7 +96,8 @@ class SpellsPerDayHeader extends JPanel {
 		gbc_label.gridy = 0;
 		add(label, gbc_label);
 		
-		JTextField textFieldOutOf = new JTextField(Integer.toString(model.numSpellsPerDayAtLevel(level)));
+		JTextField textFieldOutOf = new JTextField(getSpellPerDayListing(level, model));
+		textFieldOutOf.setHorizontalAlignment(SwingConstants.CENTER);
 		GridBagConstraints gbc_textFieldOutOf = new GridBagConstraints();
 		gbc_textFieldOutOf.insets = new Insets(0, 0, 0, 5);
 		gbc_textFieldOutOf.fill = GridBagConstraints.HORIZONTAL;
@@ -102,12 +107,21 @@ class SpellsPerDayHeader extends JPanel {
 		textFieldOutOf.setEditable(false);
 		textFieldOutOf.setColumns(10);
 		
-		listener = new ObservableListener<JTextField, DDSpellbook>(textFieldRemaining, (c, v) -> {
-			c.setText(Integer.toString(v.numSpellsPerDayRemainingAtLevel(level)));
+		listener = new IndirectObservableListener<JTextField, DDSpellbookWrapper>(textFieldRemaining, (c, v) -> {
+			c.setText(getSpellPerDayListing(level, v));
 		});
-		listener.setObserved(model);
+		if (model.getSecondary().isPresent()) {
+			listener.setObserved(model, model.getMain(), model.getSecondary().get());
+		} else {
+			listener.setObserved(model, model.getMain());
+		}
 	}
 	
+	private String getSpellPerDayListing(int level, DDSpellbookWrapper wrap) {
+		return wrap.getMain().numSpellsPerDayAtLevel(level) +
+				wrap.getSecondary().map(sb -> "+" + sb.numSpellsPerDayAtLevel(level)).orElse("");
+	}
+
 	@Override
 	public void removeNotify() {
 		super.removeNotify();