Skip to content
7 changes: 6 additions & 1 deletion cmd/enumgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,12 @@ var allEnums = []*enumInfo{
{
Name: "DRBonus",
Key: "dr_bonus",
String: "Gives a DR bonus of",
String: "Gives a Damage Resistance (DR) bonus of",
},
{
Name: "PassiveDefenseBonus",
Key: "passive_defense_bonus",
String: "Gives a Passive Defense (PD) bonus of",
},
{
Key: "reaction_bonus",
Expand Down
15 changes: 15 additions & 0 deletions model/gurps/dr_bonus.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ func NewDRBonus() *DRBonus {
}
}

// NewPassiveDefenseBonus creates a new DRBonus configured for Passive Defense (PD).
// This is a convenience function that creates a DRBonus with specialization="PD".
func NewPassiveDefenseBonus() *DRBonus {
return &DRBonus{
DRBonusData: DRBonusData{
Type: feature.PassiveDefenseBonus,
Locations: []string{TorsoID},
Specialization: "PD",
LeveledAmount: LeveledAmount{Amount: fxp.One},
},
}
}

// FeatureType implements Feature.
func (d *DRBonus) FeatureType() feature.Type {
return d.Type
Expand All @@ -75,9 +88,11 @@ func (d *DRBonus) Normalize() {
d.Locations[i] = loc
}
s := strings.TrimSpace(d.Specialization)
// Normalize empty or "All" to AllID
if s == "" || strings.EqualFold(s, AllID) {
s = AllID
}
// Note: "PD" specialization is preserved as-is (it doesn't match AllID, so it won't be normalized)
d.Specialization = s
}

Expand Down
169 changes: 168 additions & 1 deletion model/gurps/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,10 @@ func (e *Entity) AddDRBonusesFor(locationID string, tooltip *xbytes.InsertBuffer
}
}
for _, one := range e.features.drBonuses {
// Skip PD bonuses - they're handled separately by AddPDBonusesFor
if strings.EqualFold(one.Specialization, "PD") {
continue
}
for _, loc := range one.Locations {
if (loc == AllID && isTopLevel) || strings.EqualFold(loc, locationID) {
drMap[strings.ToLower(one.Specialization)] += fxp.AsInteger[int](one.AdjustedAmount())
Expand All @@ -829,6 +833,41 @@ func (e *Entity) AddDRBonusesFor(locationID string, tooltip *xbytes.InsertBuffer
return drMap
}

// AddPDBonusesFor locates any active PD (Passive Defense) bonuses for the given location and adds them to the map.
// If 'pdMap' is nil, it will be created. The provided map (or the newly created one) will be returned.
// PD only applies if UsePassiveDefense is enabled in sheet settings (GURPS 3e optional rule).
func (e *Entity) AddPDBonusesFor(locationID string, tooltip *xbytes.InsertBuffer, pdMap map[string]int) map[string]int {
if pdMap == nil {
pdMap = make(map[string]int)
}
// PD is an optional rule - only calculate if enabled
if e.SheetSettings == nil || !e.SheetSettings.UsePassiveDefense {
return pdMap
}
isTopLevel := false
for _, one := range e.SheetSettings.BodyType.Locations {
if one.LocID == locationID {
isTopLevel = true
break
}
}
for _, one := range e.features.drBonuses {
// Only process PD bonuses (specialization="PD")
if !strings.EqualFold(one.Specialization, "PD") {
continue
}
for _, loc := range one.Locations {
if (loc == AllID && isTopLevel) || strings.EqualFold(loc, locationID) {
// PD bonuses are always stored under PDSpecializationKey regardless of specialization value
pdMap[PDSpecializationKey] += fxp.AsInteger[int](one.AdjustedAmount())
one.AddToTooltip(tooltip)
break
}
}
}
return pdMap
}

// SkillBonusFor returns the total bonus for the matching skill bonuses.
func (e *Entity) SkillBonusFor(name, specialization string, tags []string, tooltip *xbytes.InsertBuffer) fxp.Int {
var total fxp.Int
Expand Down Expand Up @@ -1057,14 +1096,40 @@ func (e *Entity) SkillNamed(name, specialization string, requirePoints bool, exc
}

// Dodge returns the current Dodge value for the given Encumbrance.
// If DodgeOverride is set (non-zero), it returns that value directly without calculation.
// Note: PD (Passive Defense) does NOT affect base Dodge. PD is applied separately during
// combat resolution when an active defense fails and only if armor covers the hit location.
func (e *Entity) Dodge(enc encumbrance.Level) int {
settings := e.SheetSettings
// Check for manual override first
if settings != nil && settings.DodgeOverride != 0 {
return fxp.AsInteger[int](settings.DodgeOverride.Max(fxp.One))
}

var dodge fxp.Int
if e.ResolveAttribute(DodgeID) != nil {
dodge = e.ResolveAttributeCurrent(DodgeID)
} else {
dodge = e.ResolveAttributeCurrent(BasicSpeedID).Max(0) + fxp.Three
if settings == nil {
// Fall back to GURPS 4E defaults if settings are nil
dodge = e.ResolveAttributeCurrent(BasicSpeedID).Max(0) + fxp.Three
} else {
// Use BasicMove or BasicSpeed based on settings
if settings.UseBasicMoveForDodge {
dodge = e.ResolveAttributeCurrent(BasicMoveID).Max(0)
} else {
dodge = e.ResolveAttributeCurrent(BasicSpeedID).Max(0)
}
// Include flat +3 bonus if enabled
if settings.IncludeDodgeFlatBonus {
dodge += fxp.Three
}
}
}
dodge += e.DodgeBonus
// NOTE: PD (Passive Defense) is NOT added to base Dodge. PD is a separate mechanic
// that applies during combat resolution when an active defense fails and only
// if the armor covers the hit location. PD would be handled in combat resolution logic.
divisor := 2 * min(CountThresholdOpMet(threshold.HalveDodge, e.Attributes), 2)
if divisor > 0 {
dodge = dodge.Div(fxp.FromInteger(divisor)).Ceil()
Expand Down Expand Up @@ -1100,6 +1165,108 @@ func (e *Entity) EncumbranceLevel(forSkills bool) encumbrance.Level {
return encumbrance.ExtraHeavy
}

// PassiveDefenseFromArmor returns the total Passive Defense from equipped armor.
// PD is identified by DRBonus features with "PD" specialization (case-insensitive).
// Armor is identified as equipment that is equipped and does not have a "Shield" tag.
//
// NOTE: PD does NOT affect base Dodge. PD is a GURPS 3e mechanic that applies during
// combat resolution when an active defense (Dodge/Parry/Block) fails. PD is added to
// the failed defense roll only if the armor covers the hit location, providing a
// second chance to avoid the attack. This function is provided for use in combat
// resolution logic, not for base Dodge calculation.
func (e *Entity) PassiveDefenseFromArmor() fxp.Int {
var total fxp.Int
for _, eqp := range e.CarriedEquipment {
if !eqp.ReallyEquipped() {
continue
}
// Skip shields - they're handled by PassiveDefenseFromShields()
isShield := false
for _, tag := range eqp.Tags {
if strings.EqualFold(strings.TrimSpace(tag), "Shield") {
isShield = true
break
}
}
if isShield {
continue
}
// Look for DRBonus features with "PD" specialization
for _, f := range eqp.FeatureList() {
if drBonus, ok := f.(*DRBonus); ok {
// Check if this DRBonus has "PD" specialization (case-insensitive)
spec := strings.TrimSpace(drBonus.Specialization)
if strings.EqualFold(spec, "PD") {
total += drBonus.AdjustedAmount()
}
}
}
// Also check modifiers for PD features
for _, mod := range eqp.Modifiers {
for _, f := range mod.Features {
if drBonus, ok := f.(*DRBonus); ok {
spec := strings.TrimSpace(drBonus.Specialization)
if strings.EqualFold(spec, "PD") {
total += drBonus.AdjustedAmount()
}
}
}
}
}
return total.Floor()
}

// PassiveDefenseFromShields returns the total Passive Defense from equipped shields.
// PD is identified by DRBonus features with "PD" specialization (case-insensitive).
// Shields are identified as equipment that is equipped and has a "Shield" tag.
//
// NOTE: PD does NOT affect base Dodge. PD is a GURPS 3e mechanic that applies during
// combat resolution when an active defense (Dodge/Parry/Block) fails. PD is added to
// the failed defense roll only if the armor covers the hit location, providing a
// second chance to avoid the attack. This function is provided for use in combat
// resolution logic, not for base Dodge calculation.
func (e *Entity) PassiveDefenseFromShields() fxp.Int {
var total fxp.Int
for _, eqp := range e.CarriedEquipment {
if !eqp.ReallyEquipped() {
continue
}
// Only process items with "Shield" tag
isShield := false
for _, tag := range eqp.Tags {
if strings.EqualFold(strings.TrimSpace(tag), "Shield") {
isShield = true
break
}
}
if !isShield {
continue
}
// Look for DRBonus features with "PD" specialization
for _, f := range eqp.FeatureList() {
if drBonus, ok := f.(*DRBonus); ok {
// Check if this DRBonus has "PD" specialization (case-insensitive)
spec := strings.TrimSpace(drBonus.Specialization)
if strings.EqualFold(spec, "PD") {
total += drBonus.AdjustedAmount()
}
}
}
// Also check modifiers for PD features
for _, mod := range eqp.Modifiers {
for _, f := range mod.Features {
if drBonus, ok := f.(*DRBonus); ok {
spec := strings.TrimSpace(drBonus.Specialization)
if strings.EqualFold(spec, "PD") {
total += drBonus.AdjustedAmount()
}
}
}
}
}
return total.Floor()
}

// WeightUnit returns the weight unit that should be used for display.
func (e *Entity) WeightUnit() fxp.WeightUnit {
return e.SheetSettings.DefaultWeightUnits
Expand Down
6 changes: 6 additions & 0 deletions model/gurps/enums/feature/type_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions model/gurps/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ func (f *Features) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
feat = &CostReduction{}
case feature.DRBonus:
feat = &DRBonus{}
case feature.PassiveDefenseBonus:
feat = &DRBonus{} // PassiveDefenseBonus is a DRBonus with PD specialization
case feature.ReactionBonus:
feat = &ReactionBonus{}
case feature.SkillBonus:
Expand Down
47 changes: 47 additions & 0 deletions model/gurps/hit_location.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,53 @@ func (h *HitLocation) DR(entity *Entity, tooltip *xbytes.InsertBuffer, drMap map
return drMap
}

// PD computes the PD (Passive Defense) coverage for this HitLocation. If 'tooltip' isn't nil, the buffer will be
// updated with details on how the PD was calculated. If 'pdMap' isn't nil, it will be returned.
// PD only applies if UsePassiveDefense is enabled in sheet settings (GURPS 3e optional rule).
func (h *HitLocation) PD(entity *Entity, tooltip *xbytes.InsertBuffer, pdMap map[string]int) map[string]int {
if pdMap == nil {
pdMap = make(map[string]int)
}
// PD is an optional rule - only calculate if enabled
if entity.SheetSettings == nil || !entity.SheetSettings.UsePassiveDefense {
return pdMap
}
pdMap = entity.AddPDBonusesFor(h.LocID, tooltip, pdMap)
if h.owningTable != nil && h.owningTable.owningLocation != nil {
pdMap = h.owningTable.owningLocation.PD(entity, tooltip, pdMap)
}
if tooltip != nil && len(pdMap) != 0 {
// PD is simpler than DR - it doesn't have specializations, just a total value
// stored under PDSpecializationKey. Show the total PD value.
pdValue := pdMap[PDSpecializationKey]
if pdValue > 0 {
var buffer bytes.Buffer
buffer.WriteByte('\n')
fmt.Fprintf(&buffer, i18n.Text("\n- **PD %d**"), pdValue)
buffer.WriteString("\n---\n")
_ = tooltip.Insert(0, buffer.Bytes())
}
}
return pdMap
}

// DisplayPD returns the PD for this location, formatted as a string.
// Returns just the total PD value (not broken down by specialization like DR).
func (h *HitLocation) DisplayPD(entity *Entity, tooltip *xbytes.InsertBuffer) string {
if entity.SheetSettings == nil || !entity.SheetSettings.UsePassiveDefense {
return "0"
}
pdMap := h.PD(entity, tooltip, nil)
// PD bonuses are stored under the PDSpecializationKey ("pd") from AddPDBonusesFor.
// All PD bonuses for a location are accumulated under this single key.
// For display, we just want the total PD value, not a ratio like DR.
pdValue := pdMap[PDSpecializationKey]
if pdValue == 0 {
return "0"
}
return strconv.Itoa(pdValue)
}

// DisplayDR returns the DR for this location, formatted as a string.
func (h *HitLocation) DisplayDR(entity *Entity, tooltip *xbytes.InsertBuffer) string {
drMap := h.DR(entity, tooltip, nil)
Expand Down
4 changes: 4 additions & 0 deletions model/gurps/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const (
TorsoID = "torso"
)

// PDSpecializationKey is the key used in DR bonus maps for Passive Defense bonuses.
// PD bonuses use specialization="PD" which gets lowercased to "pd" in the map.
const PDSpecializationKey = "pd"

// SanitizeID ensures the ID is not empty and consists of only lowercase alphanumeric characters. If permitLeadingDigits
// is false, then leading digits are stripped. A list of reserved values can be passed in to disallow specific IDs.
func SanitizeID(id string, permitLeadingDigits bool, reserved ...string) string {
Expand Down
Loading