Skip to content

Commit be9ce10

Browse files
hyperpolymathclaude
andcommitted
feat: implement Jessica's skill tree — 34 abilities across 7 categories
Add SkillAbilities.res with 28 generic abilities (7 categories x 4 ranks) and 6 background-exclusive Expert abilities. Transform SkillTreeScreen from linear XP bars to branching tree with ability nodes, connector lines, rank colours, and exclusive star markers. Wire into PlayerState.hasAbility for gameplay queries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 75942ac commit be9ce10

4 files changed

Lines changed: 1118 additions & 29 deletions

File tree

__tests__/SkillAbilities_test.res

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// SkillAbilities_test.res Unit tests for Jessica's skill ability system.
3+
//
4+
// Tests cover:
5+
// genericAbilities — correct count and structure
6+
// exclusiveAbilities — correct count and background assignments
7+
// getAbilitiesForCategory — returns abilities for one category
8+
// getUnlockedAbilities — rank-gated unlocking logic
9+
// isAbilityUnlocked — individual ability checks
10+
// background exclusivity — exclusive abilities only for correct background
11+
// rank progression — Novice unlocks nothing, Expert unlocks all 4
12+
13+
// ===========================================================================
14+
// Generic abilities — 28 total (7 categories x 4 ranks)
15+
// ===========================================================================
16+
17+
let testGenericAbilitiesCount = (): promise<bool> => {
18+
Promise.resolve(Array.length(SkillAbilities.genericAbilities) == 28)
19+
}
20+
21+
let testExclusiveAbilitiesCount = (): promise<bool> => {
22+
Promise.resolve(Array.length(SkillAbilities.exclusiveAbilities) == 6)
23+
}
24+
25+
let testAllAbilitiesCount = (): promise<bool> => {
26+
// 28 generic + 6 exclusive = 34 total
27+
Promise.resolve(Array.length(SkillAbilities.allAbilities) == 34)
28+
}
29+
30+
// ===========================================================================
31+
// getAbilitiesForCategory — returns all abilities for one category
32+
// ===========================================================================
33+
34+
/// Each category has exactly 4 generic abilities. Some categories also have
35+
/// exclusive abilities (Combat, Observation, TechLiteracy, Fieldcraft,
36+
/// Composure each have 1; TechLiteracy has 2 — Engineer and Signals).
37+
let testAbilitiesForInfiltration = (): promise<bool> => {
38+
let abilities = SkillAbilities.getAbilitiesForCategory(Infiltration)
39+
// 4 generic, 0 exclusive
40+
Promise.resolve(Array.length(abilities) == 4)
41+
}
42+
43+
let testAbilitiesForCombat = (): promise<bool> => {
44+
let abilities = SkillAbilities.getAbilitiesForCategory(CombatSkill)
45+
// 4 generic + 1 exclusive (Blitz for Assault)
46+
Promise.resolve(Array.length(abilities) == 5)
47+
}
48+
49+
let testAbilitiesForTechLiteracy = (): promise<bool> => {
50+
let abilities = SkillAbilities.getAbilitiesForCategory(TechLiteracy)
51+
// 4 generic + 2 exclusive (Improvise for Engineer, Override for Signals)
52+
Promise.resolve(Array.length(abilities) == 6)
53+
}
54+
55+
let testAbilitiesForCategoryAndBackground = (): promise<bool> => {
56+
// Assault operative sees 4 generic + 1 exclusive for Combat
57+
let abilities = SkillAbilities.getAbilitiesForCategoryAndBackground(CombatSkill, Assault)
58+
Promise.resolve(Array.length(abilities) == 5)
59+
}
60+
61+
let testAbilitiesForCategoryExcludesOtherBackground = (): promise<bool> => {
62+
// Recon operative sees only 4 generic for Combat (no Blitz)
63+
let abilities = SkillAbilities.getAbilitiesForCategoryAndBackground(CombatSkill, Recon)
64+
Promise.resolve(Array.length(abilities) == 4)
65+
}
66+
67+
// ===========================================================================
68+
// Novice rank unlocks nothing
69+
// ===========================================================================
70+
71+
let testNoviceUnlocksNothing = (): promise<bool> => {
72+
// All skills at Novice (default for a non-specialist category)
73+
let skills = JessicaBackground.makeSkillSet(Assault)
74+
// Assault starts combat at Trained, but infiltration at Novice
75+
let infiltrationAbilities = SkillAbilities.getUnlockedAbilities(
76+
skills,
77+
~background=Assault,
78+
)->Array.filter(a => a.category == Infiltration)
79+
Promise.resolve(Array.length(infiltrationAbilities) == 0)
80+
}
81+
82+
// ===========================================================================
83+
// Trained rank unlocks exactly 1 ability per category
84+
// ===========================================================================
85+
86+
let testTrainedUnlocksOne = (): promise<bool> => {
87+
// Assault starts with combat at Trained
88+
let skills = JessicaBackground.makeSkillSet(Assault)
89+
let combatAbilities = SkillAbilities.getUnlockedAbilities(
90+
skills,
91+
~background=Assault,
92+
)->Array.filter(a => a.category == CombatSkill)
93+
// Trained unlocks Quick Strike (1 ability)
94+
Promise.resolve(Array.length(combatAbilities) == 1)
95+
}
96+
97+
let testTrainedUnlocksCorrectAbility = (): promise<bool> => {
98+
let skills = JessicaBackground.makeSkillSet(Assault)
99+
let isUnlocked = SkillAbilities.isAbilityUnlocked(
100+
skills,
101+
~background=Assault,
102+
"combat_trained_quick_strike",
103+
)
104+
Promise.resolve(isUnlocked)
105+
}
106+
107+
// ===========================================================================
108+
// Expert rank unlocks all 4 abilities per category
109+
// ===========================================================================
110+
111+
let testExpertUnlocksAll = (): promise<bool> => {
112+
let skills = JessicaBackground.makeSkillSet(Assault)
113+
// Manually promote infiltration to Expert
114+
skills.infiltration.rank = Expert
115+
let infiltrationAbilities = SkillAbilities.getUnlockedAbilities(
116+
skills,
117+
~background=Assault,
118+
)->Array.filter(a => a.category == Infiltration)
119+
// Expert unlocks all 4 generic abilities (no exclusive for Infiltration+Assault)
120+
Promise.resolve(Array.length(infiltrationAbilities) == 4)
121+
}
122+
123+
let testExpertUnlocksIncludingExclusive = (): promise<bool> => {
124+
let skills = JessicaBackground.makeSkillSet(Assault)
125+
// Promote combat to Expert (Assault specialism)
126+
skills.combat.rank = Expert
127+
let combatAbilities = SkillAbilities.getUnlockedAbilities(
128+
skills,
129+
~background=Assault,
130+
)->Array.filter(a => a.category == CombatSkill)
131+
// 4 generic + 1 exclusive (Blitz) = 5
132+
Promise.resolve(Array.length(combatAbilities) == 5)
133+
}
134+
135+
// ===========================================================================
136+
// Background-exclusive abilities only show for correct background
137+
// ===========================================================================
138+
139+
let testExclusiveAbilityCorrectBackground = (): promise<bool> => {
140+
let skills = JessicaBackground.makeSkillSet(Assault)
141+
skills.combat.rank = Expert
142+
let isUnlocked = SkillAbilities.isAbilityUnlocked(
143+
skills,
144+
~background=Assault,
145+
"combat_expert_blitz",
146+
)
147+
Promise.resolve(isUnlocked)
148+
}
149+
150+
let testExclusiveAbilityWrongBackground = (): promise<bool> => {
151+
let skills = JessicaBackground.makeSkillSet(Recon)
152+
skills.combat.rank = Expert
153+
// Recon operative at Expert Combat still cannot use Blitz
154+
let isUnlocked = SkillAbilities.isAbilityUnlocked(
155+
skills,
156+
~background=Recon,
157+
"combat_expert_blitz",
158+
)
159+
Promise.resolve(!isUnlocked)
160+
}
161+
162+
let testExclusiveAbilityNoBackground = (): promise<bool> => {
163+
let skills = JessicaBackground.makeSkillSet(Assault)
164+
skills.combat.rank = Expert
165+
// Without specifying background, exclusive abilities are locked
166+
let isUnlocked = SkillAbilities.isAbilityUnlocked(
167+
skills,
168+
"combat_expert_blitz",
169+
)
170+
Promise.resolve(!isUnlocked)
171+
}
172+
173+
let testOmniscienceOnlyForRecon = (): promise<bool> => {
174+
let skills = JessicaBackground.makeSkillSet(Recon)
175+
skills.observation.rank = Expert
176+
let recon = SkillAbilities.isAbilityUnlocked(
177+
skills,
178+
~background=Recon,
179+
"observation_expert_omniscience",
180+
)
181+
let assault = SkillAbilities.isAbilityUnlocked(
182+
skills,
183+
~background=Assault,
184+
"observation_expert_omniscience",
185+
)
186+
Promise.resolve(recon && !assault)
187+
}
188+
189+
let testPhoenixOnlyForMedic = (): promise<bool> => {
190+
let skills = JessicaBackground.makeSkillSet(Medic)
191+
skills.composure.rank = Expert
192+
let medic = SkillAbilities.isAbilityUnlocked(
193+
skills,
194+
~background=Medic,
195+
"composure_expert_phoenix",
196+
)
197+
let assault = SkillAbilities.isAbilityUnlocked(
198+
skills,
199+
~background=Assault,
200+
"composure_expert_phoenix",
201+
)
202+
Promise.resolve(medic && !assault)
203+
}
204+
205+
let testResupplyDropOnlyForLogistics = (): promise<bool> => {
206+
let skills = JessicaBackground.makeSkillSet(Logistics)
207+
skills.fieldcraft.rank = Expert
208+
let logistics = SkillAbilities.isAbilityUnlocked(
209+
skills,
210+
~background=Logistics,
211+
"fieldcraft_expert_resupply_drop",
212+
)
213+
let recon = SkillAbilities.isAbilityUnlocked(
214+
skills,
215+
~background=Recon,
216+
"fieldcraft_expert_resupply_drop",
217+
)
218+
Promise.resolve(logistics && !recon)
219+
}
220+
221+
// ===========================================================================
222+
// getUnlockedAbilities returns correct count for various skill states
223+
// ===========================================================================
224+
225+
let testUnlockedCountAllNovice = (): promise<bool> => {
226+
// Assault starts combat at Trained => 1 ability unlocked
227+
let skills = JessicaBackground.makeSkillSet(Assault)
228+
let unlocked = SkillAbilities.getUnlockedAbilities(skills, ~background=Assault)
229+
Promise.resolve(Array.length(unlocked) == 1)
230+
}
231+
232+
let testUnlockedCountMixedRanks = (): promise<bool> => {
233+
let skills = JessicaBackground.makeSkillSet(Recon)
234+
// Recon starts observation at Trained (1 ability)
235+
// Promote infiltration to Proficient (2 abilities)
236+
skills.infiltration.rank = Proficient
237+
let unlocked = SkillAbilities.getUnlockedAbilities(skills, ~background=Recon)
238+
// 1 (observation Trained) + 2 (infiltration Trained+Proficient) = 3
239+
Promise.resolve(Array.length(unlocked) == 3)
240+
}
241+
242+
let testUnlockedCountFullExpert = (): promise<bool> => {
243+
let skills = JessicaBackground.makeSkillSet(Assault)
244+
// Promote all skills to Expert
245+
skills.infiltration.rank = Expert
246+
skills.combat.rank = Expert
247+
skills.athletics.rank = Expert
248+
skills.observation.rank = Expert
249+
skills.techLiteracy.rank = Expert
250+
skills.fieldcraft.rank = Expert
251+
skills.composure.rank = Expert
252+
let unlocked = SkillAbilities.getUnlockedAbilities(skills, ~background=Assault)
253+
// 28 generic + 1 exclusive (Blitz for Assault/Combat) = 29
254+
Promise.resolve(Array.length(unlocked) == 29)
255+
}
256+
257+
// ===========================================================================
258+
// isAbilityUnlocked — unknown ability returns false
259+
// ===========================================================================
260+
261+
let testUnknownAbilityReturnsFalse = (): promise<bool> => {
262+
let skills = JessicaBackground.makeSkillSet(Assault)
263+
let isUnlocked = SkillAbilities.isAbilityUnlocked(
264+
skills,
265+
~background=Assault,
266+
"nonexistent_ability",
267+
)
268+
Promise.resolve(!isUnlocked)
269+
}
270+
271+
// ===========================================================================
272+
// findAbility — lookup by ID
273+
// ===========================================================================
274+
275+
let testFindAbilityExists = (): promise<bool> => {
276+
switch SkillAbilities.findAbility("infiltration_trained_silent_walk") {
277+
| Some(a) => Promise.resolve(a.name == "Silent Walk")
278+
| None => Promise.resolve(false)
279+
}
280+
}
281+
282+
let testFindAbilityMissing = (): promise<bool> => {
283+
Promise.resolve(SkillAbilities.findAbility("nonexistent") == None)
284+
}
285+
286+
// ===========================================================================
287+
// Rank progression — proficient unlocks 2, veteran unlocks 3
288+
// ===========================================================================
289+
290+
let testProficientUnlocksTwo = (): promise<bool> => {
291+
let skills = JessicaBackground.makeSkillSet(Assault)
292+
skills.infiltration.rank = Proficient
293+
let infiltrationAbilities = SkillAbilities.getUnlockedAbilities(
294+
skills,
295+
~background=Assault,
296+
)->Array.filter(a => a.category == Infiltration)
297+
Promise.resolve(Array.length(infiltrationAbilities) == 2)
298+
}
299+
300+
let testVeteranUnlocksThree = (): promise<bool> => {
301+
let skills = JessicaBackground.makeSkillSet(Assault)
302+
skills.infiltration.rank = Veteran
303+
let infiltrationAbilities = SkillAbilities.getUnlockedAbilities(
304+
skills,
305+
~background=Assault,
306+
)->Array.filter(a => a.category == Infiltration)
307+
Promise.resolve(Array.length(infiltrationAbilities) == 3)
308+
}
309+
310+
// ===========================================================================
311+
// Each exclusive ability has correct category mapping
312+
// ===========================================================================
313+
314+
let testAllExclusivesHaveBackground = (): promise<bool> => {
315+
let allHaveBg = SkillAbilities.exclusiveAbilities->Array.every(a =>
316+
a.exclusiveBackground != None
317+
)
318+
Promise.resolve(allHaveBg)
319+
}
320+
321+
let testAllExclusivesAreExpert = (): promise<bool> => {
322+
let allExpert = SkillAbilities.exclusiveAbilities->Array.every(a =>
323+
a.requiredRank == Expert
324+
)
325+
Promise.resolve(allExpert)
326+
}
327+
328+
let testAllGenericHaveNoBackground = (): promise<bool> => {
329+
let allNone = SkillAbilities.genericAbilities->Array.every(a =>
330+
a.exclusiveBackground == None
331+
)
332+
Promise.resolve(allNone)
333+
}
334+
335+
// ===========================================================================
336+
// Unique IDs — no duplicates
337+
// ===========================================================================
338+
339+
let testUniqueIds = (): promise<bool> => {
340+
let ids = SkillAbilities.allAbilities->Array.map(a => a.id)
341+
let uniqueIds = Set.fromArray(ids)
342+
Promise.resolve(Set.size(uniqueIds) == Array.length(ids))
343+
}
344+
345+
// ===========================================================================
346+
// Suite
347+
// ===========================================================================
348+
349+
let suite: TestRunner.suite = {
350+
name: "SkillAbilities (Jessica's skill ability tree)",
351+
cases: [
352+
{name: "genericAbilities: 28 total (7 categories x 4 ranks)", run: testGenericAbilitiesCount},
353+
{name: "exclusiveAbilities: 6 total (one per background)", run: testExclusiveAbilitiesCount},
354+
{name: "allAbilities: 34 total (28 + 6)", run: testAllAbilitiesCount},
355+
{name: "getAbilitiesForCategory: Infiltration = 4", run: testAbilitiesForInfiltration},
356+
{name: "getAbilitiesForCategory: Combat = 5 (4 generic + 1 exclusive)", run: testAbilitiesForCombat},
357+
{name: "getAbilitiesForCategory: TechLiteracy = 6 (4 generic + 2 exclusive)", run: testAbilitiesForTechLiteracy},
358+
{name: "getAbilitiesForCategoryAndBackground: Assault/Combat = 5", run: testAbilitiesForCategoryAndBackground},
359+
{name: "getAbilitiesForCategoryAndBackground: excludes other bg", run: testAbilitiesForCategoryExcludesOtherBackground},
360+
{name: "Novice rank unlocks nothing", run: testNoviceUnlocksNothing},
361+
{name: "Trained rank unlocks exactly 1 ability", run: testTrainedUnlocksOne},
362+
{name: "Trained rank unlocks correct ability (Quick Strike)", run: testTrainedUnlocksCorrectAbility},
363+
{name: "Proficient rank unlocks 2 abilities", run: testProficientUnlocksTwo},
364+
{name: "Veteran rank unlocks 3 abilities", run: testVeteranUnlocksThree},
365+
{name: "Expert rank unlocks all 4 generic abilities", run: testExpertUnlocksAll},
366+
{name: "Expert rank unlocks generic + exclusive", run: testExpertUnlocksIncludingExclusive},
367+
{name: "exclusive: Blitz only for Assault", run: testExclusiveAbilityCorrectBackground},
368+
{name: "exclusive: Blitz locked for Recon", run: testExclusiveAbilityWrongBackground},
369+
{name: "exclusive: Blitz locked without background", run: testExclusiveAbilityNoBackground},
370+
{name: "exclusive: Omniscience only for Recon", run: testOmniscienceOnlyForRecon},
371+
{name: "exclusive: Phoenix only for Medic", run: testPhoenixOnlyForMedic},
372+
{name: "exclusive: Resupply Drop only for Logistics", run: testResupplyDropOnlyForLogistics},
373+
{name: "getUnlockedAbilities: Assault default = 1", run: testUnlockedCountAllNovice},
374+
{name: "getUnlockedAbilities: mixed ranks = 3", run: testUnlockedCountMixedRanks},
375+
{name: "getUnlockedAbilities: full Expert = 29", run: testUnlockedCountFullExpert},
376+
{name: "isAbilityUnlocked: unknown ability = false", run: testUnknownAbilityReturnsFalse},
377+
{name: "findAbility: exists", run: testFindAbilityExists},
378+
{name: "findAbility: missing = None", run: testFindAbilityMissing},
379+
{name: "all exclusives have a background", run: testAllExclusivesHaveBackground},
380+
{name: "all exclusives require Expert rank", run: testAllExclusivesAreExpert},
381+
{name: "all generics have no background restriction", run: testAllGenericHaveNoBackground},
382+
{name: "all ability IDs are unique", run: testUniqueIds},
383+
],
384+
}

0 commit comments

Comments
 (0)