Advanced skills can no longer be unlocked through proficiency picks

Advanced skills must be unlocked with the pool cost. Only a profession's
own proficiency still unlocks its skill for free. Proficiency picks need
an unlocked skill, locking a skill returns its picks, old characters that
used picks to unlock a skill get it unlocked automatically.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-20 20:48:45 +00:00
parent 4c0d7906cd
commit a2e719afd0
6 changed files with 21 additions and 19 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
### Skills, proficiency and expertise
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if a profession or proficiency covers it).
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
+1 -1
View File
@@ -37,7 +37,7 @@ def compute(ch: dict, rules: dict) -> dict:
pips = prof_pip + max(0, free_pips)
prof = [0, cr["proficiency_bonus"], cr["expertise_bonus"]][pips]
unlocked = s["id"] in (ch.get("unlocked") or [])
usable = s["type"] == "basic" or unlocked or prof > 0 # advanced skills must be unlocked
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
"total": best + pts + prof})
+1 -1
View File
@@ -421,7 +421,7 @@
"Proficiency: +10. Expertise: +20.",
"Every character gets 2 free proficiency picks on top of the profession's proficiencies. A talent can grant 2 more.",
"Putting two proficiency picks into the same skill makes it Expertise (a profession proficiency counts as one of the two).",
"Proficiency in an advanced skill unlocks it for free.",
"Proficiency picks can only be used on skills you can use, so an advanced skill has to be unlocked first.",
"Groups: Combat, Survival, Social, Technical, Knowledge & Mental."
]
},
+7 -6
View File
@@ -134,6 +134,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
// Click on pip k (1 = Proficient, 2 = Expertise) of a skill: sets the skill's total pips to k, or one lower if already there.
const clickPip = (s, k) => {
if (!s.usable) return flash('Unlock this advanced skill first.')
const target = s.pips >= k ? k - 1 : k
const wantFree = Math.max(0, target - s.profPip)
if (wantFree - s.freePips > c.profLeft) return flash('No proficiency picks left.')
@@ -263,7 +264,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<h2>Skills</h2>
<p className="muted">
At most half the attribute per skill. Advanced skills must be unlocked first for {c.unlockCost} points from the pool
(free if your profession grants them). The pool size comes from the rules preset.
(free if your profession grants proficiency in them). The pool size comes from the rules preset.
</p>
<div className="pools">
<div className={`pool ${c.skillLeft < 0 ? 'over' : ''}`}>
@@ -275,7 +276,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
</div>
<p className="small muted">
Proficiency adds +{cr.proficiency_bonus}. Two picks on the same skill make it Expertise (+{cr.expertise_bonus}); a profession proficiency counts as one of them.
Proficiency in an advanced skill unlocks it for free. The talent "Versatile Training" grants 2 more picks.
Proficiency picks only work on skills you can use, so unlock an advanced skill first. The talent "Versatile Training" grants 2 more picks.
</p>
{rules.skill_groups.map((g) => (
<div key={g.id}>
@@ -295,8 +296,8 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<span className="small muted">Training</span>
{[1, 2].map((k) => (
<button key={k} className={`pip ${s.pips >= k ? 'on' : ''} ${s.profPip >= k ? 'from-prof' : ''}`}
disabled={s.profPip >= k} onClick={() => clickPip(s, k)}
title={s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
disabled={s.profPip >= k || !s.usable} onClick={() => clickPip(s, k)}
title={!s.usable ? 'Unlock the skill first' : s.profPip >= k ? 'From your profession' : k === 2 ? 'Expertise (uses 2 picks in total)' : 'Proficiency'}
aria-label={`${s.name} proficiency ${k}`}>{s.pips >= k ? '◆' : '◇'}</button>
))}
</div>
@@ -309,8 +310,8 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
) : (
<div className="stepper">
{s.type === 'advanced' && !s.free && (
<button className="ghost small-btn" title={`Lock again and get ${c.unlockCost} points back`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 } }))}>Lock</button>
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
+9 -8
View File
@@ -82,19 +82,20 @@ export function normalize(ch, rules) {
}
// The preset may have been deleted since: fall back to whatever matches the numbers
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
// Older characters: advanced skills that already hold points count as unlocked
// Older characters: advanced skills that already hold points or proficiency picks count as unlocked
const unlocked = new Set(out.unlocked)
rules.skills.forEach((s) => { if (s.type === 'advanced' && (out.skill_points[s.id] || 0) > 0) unlocked.add(s.id) })
rules.skills.forEach((s) => { if (s.type === 'advanced' && ((out.skill_points[s.id] || 0) > 0 || (out.proficiency_picks[s.id] || 0) > 0)) unlocked.add(s.id) })
return { ...out, unlocked: [...unlocked] }
}
// Clears points on advanced skills that are neither unlocked nor granted by a profession proficiency.
// Clears points and proficiency picks on advanced skills that are neither unlocked nor granted by the profession.
export function pruneLocked(ch, rules) {
const points = { ...ch.skill_points }
const picks = { ...ch.proficiency_picks }
rules.skills.forEach((s) => {
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id] && !(ch.proficiency_picks[s.id] > 0)) delete points[s.id]
if (s.type === 'advanced' && !ch.unlocked.includes(s.id) && !ch.proficiencies[s.id]) { delete points[s.id]; delete picks[s.id] }
})
return { ...ch, skill_points: points }
return { ...ch, skill_points: points, proficiency_picks: picks }
}
export function kitItems(ch, rules) {
@@ -125,15 +126,15 @@ export function compute(ch, rules) {
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
const unlocked = ch.unlocked.includes(s.id)
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
usable: s.type === 'basic' || unlocked || prof > 0, // advanced skills must be unlocked
free: s.type === 'advanced' && prof > 0, // unlocked by the profession, costs nothing
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
max: Math.floor(best / cr.skill_max_divisor) }
})
const profSpent = skills.reduce((s, x) => s + x.freePips, 0)
const profAllowed = (cr.free_proficiencies ?? 0) + rules.talents.filter((t) => ch.talents.includes(t.id)).reduce((s, t) => s + (t.proficiency_picks || 0), 0)
const unlockCost = cr.advanced_unlock_cost ?? 0
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length
const paidUnlocks = skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.profPip).length
const attrSpent = rules.attributes.reduce((s, a) => s + (ch.attributes[a.id] - cr.attr_start), 0)
const skillSpent = Object.values(ch.skill_points).reduce((s, v) => s + v, 0) + paidUnlocks * unlockCost
const chosen = rules.augments.filter((a) => ch.augments.includes(a.id))
+2 -2
View File
@@ -33,9 +33,9 @@ export function explainSkill(s, { ch, rules, c }) {
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
]
if (s.type === 'advanced') {
lines.push(s.free ? 'Advanced skill: unlocked for free by a proficiency.'
lines.push(s.free ? 'Advanced skill: unlocked for free by your profession.'
: s.unlocked ? `Advanced skill: unlocked (cost ${c.unlockCost} points from the pool).`
: `Advanced skill: locked. Unlock it for ${c.unlockCost} points, or get a proficiency in it.`)
: `Advanced skill: locked. Unlock it for ${c.unlockCost} points from the pool before you can use it.`)
}
if (s.pips === 1 && s.profPip) lines.push('Proficient through your profession.')
return lines