diff --git a/cmd/enumgen/main.go b/cmd/enumgen/main.go index 02d3abe01..401dcca6f 100644 --- a/cmd/enumgen/main.go +++ b/cmd/enumgen/main.go @@ -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", diff --git a/model/gurps/dr_bonus.go b/model/gurps/dr_bonus.go index fe2351994..c7b32546f 100644 --- a/model/gurps/dr_bonus.go +++ b/model/gurps/dr_bonus.go @@ -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 @@ -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 } diff --git a/model/gurps/entity.go b/model/gurps/entity.go index b39508d6b..37daf6ed2 100644 --- a/model/gurps/entity.go +++ b/model/gurps/entity.go @@ -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()) @@ -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 @@ -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() @@ -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 diff --git a/model/gurps/enums/feature/type_gen.go b/model/gurps/enums/feature/type_gen.go index de60f25db..a28ebce4a 100644 --- a/model/gurps/enums/feature/type_gen.go +++ b/model/gurps/enums/feature/type_gen.go @@ -22,6 +22,7 @@ const ( AttributeBonus Type = iota ConditionalModifier DRBonus + PassiveDefenseBonus ReactionBonus SkillBonus SkillPointBonus @@ -64,6 +65,7 @@ var Types = []Type{ AttributeBonus, ConditionalModifier, DRBonus, + PassiveDefenseBonus, ReactionBonus, SkillBonus, SkillPointBonus, @@ -118,6 +120,8 @@ func (enum Type) Key() string { return "conditional_modifier" case DRBonus: return "dr_bonus" + case PassiveDefenseBonus: + return "passive_defense_bonus" case ReactionBonus: return "reaction_bonus" case SkillBonus: @@ -196,6 +200,8 @@ func (enum Type) String() string { return i18n.Text(`Gives a conditional modifier of`) case DRBonus: return i18n.Text(`Gives a DR bonus of`) + case PassiveDefenseBonus: + return i18n.Text(`Gives a Passive Defense (PD) bonus of`) case ReactionBonus: return i18n.Text(`Gives a reaction modifier of`) case SkillBonus: diff --git a/model/gurps/features.go b/model/gurps/features.go index 6a08ac458..e6d1dc541 100644 --- a/model/gurps/features.go +++ b/model/gurps/features.go @@ -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: diff --git a/model/gurps/hit_location.go b/model/gurps/hit_location.go index 47f19085f..e857b9e90 100644 --- a/model/gurps/hit_location.go +++ b/model/gurps/hit_location.go @@ -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) diff --git a/model/gurps/ids.go b/model/gurps/ids.go index e6e474b08..112ea7c0f 100644 --- a/model/gurps/ids.go +++ b/model/gurps/ids.go @@ -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 { diff --git a/model/gurps/sheet_settings.go b/model/gurps/sheet_settings.go index af6c5dbe7..1fa22a955 100644 --- a/model/gurps/sheet_settings.go +++ b/model/gurps/sheet_settings.go @@ -56,6 +56,22 @@ type SheetSettingsData struct { ExcludeUnspentPointsFromTotal bool `json:"exclude_unspent_points_from_total,omitzero"` ShowLiftingSTDamage bool `json:"show_lifting_st_damage,omitzero"` ShowIQBasedDamage bool `json:"show_iq_based_damage,omitzero"` + UseSkillModifierAdjustments bool `json:"use_skill_modifier_adjustments,omitzero"` + EasySkillModifierOverride fxp.Int `json:"easy_skill_modifier_override,omitzero"` + AverageSkillModifierOverride fxp.Int `json:"average_skill_modifier_override,omitzero"` + HardSkillModifierOverride fxp.Int `json:"hard_skill_modifier_override,omitzero"` + VeryHardSkillModifierOverride fxp.Int `json:"very_hard_skill_modifier_override,omitzero"` + EasySkillModifierAdjustment fxp.Int `json:"easy_skill_modifier_adjustment,omitzero"` + AverageSkillModifierAdjustment fxp.Int `json:"average_skill_modifier_adjustment,omitzero"` + HardSkillModifierAdjustment fxp.Int `json:"hard_skill_modifier_adjustment,omitzero"` + VeryHardSkillModifierAdjustment fxp.Int `json:"very_hard_skill_modifier_adjustment,omitzero"` + UseBasicMoveForDodge bool `json:"use_basic_move_for_dodge,omitzero"` + IncludeDodgeFlatBonus bool `json:"include_dodge_flat_bonus,omitzero"` + IncludePDArmor bool `json:"include_pd_armor,omitzero"` + IncludePDShields bool `json:"include_pd_shields,omitzero"` + UsePassiveDefense bool `json:"use_passive_defense,omitzero"` // GURPS 3e optional rule: PD applies when active defense fails (also shows PD column) + ShowPDColumn bool `json:"show_pd_column,omitzero"` // DEPRECATED: Automatically synced with UsePassiveDefense in EnsureValidity(). Kept for backward compatibility with old character sheets. + DodgeOverride fxp.Int `json:"dodge_override,omitzero"` } // SheetSettings holds sheet settings. @@ -88,6 +104,12 @@ func FactorySheetSettings() *SheetSettings { NotesDisplay: display.Inline, SkillLevelAdjDisplay: display.Tooltip, ShowSpellAdj: true, + // GURPS 4E defaults: Use Basic Speed, include flat +3, no PD + UseBasicMoveForDodge: false, + IncludeDodgeFlatBonus: true, + IncludePDArmor: false, + IncludePDShields: false, + UsePassiveDefense: false, // PD is a GURPS 3e optional rule, disabled by default (automatically shows PD column when enabled) }, } } @@ -137,6 +159,28 @@ func (s *SheetSettings) EnsureValidity() { s.ModifiersDisplay = s.ModifiersDisplay.EnsureValid() s.NotesDisplay = s.NotesDisplay.EnsureValid() s.SkillLevelAdjDisplay = s.SkillLevelAdjDisplay.EnsureValid() + // Ensure GURPS 4E defaults for dodge calculation fields + // This handles backward compatibility for character sheets created before dodge customization was added. + // We use a conservative heuristic: only set defaults if BOTH dodge fields AND skill modifier fields + // are at their zero values, which strongly indicates an old character sheet where these fields + // were never present in the JSON (and thus defaulted to zero values). + // This avoids incorrectly setting defaults if a user explicitly sets all dodge fields to false + // in a new character sheet (which would be very unusual anyway). + // NOTE: PD (Passive Defense) fields are not checked here as PD does not affect base Dodge. + // PD is a separate mechanic that applies during combat resolution when an active defense fails. + dodgeFieldsAtDefaults := !s.IncludeDodgeFlatBonus && !s.UseBasicMoveForDodge + skillModifierFieldsAtDefaults := !s.UseSkillModifierAdjustments && + s.EasySkillModifierOverride == 0 && s.AverageSkillModifierOverride == 0 && + s.HardSkillModifierOverride == 0 && s.VeryHardSkillModifierOverride == 0 && + s.EasySkillModifierAdjustment == 0 && s.AverageSkillModifierAdjustment == 0 && + s.HardSkillModifierAdjustment == 0 && s.VeryHardSkillModifierAdjustment == 0 + if dodgeFieldsAtDefaults && skillModifierFieldsAtDefaults { + // Both feature sets at zero values - very likely an old character sheet, set GURPS 4E defaults + s.IncludeDodgeFlatBonus = true // GURPS 4E includes flat +3 bonus + // Other fields are already false, which matches GURPS 4E defaults + } + // Ensure ShowPDColumn is always synced with UsePassiveDefense + s.ShowPDColumn = s.UsePassiveDefense } // MarshalJSONTo implements json.MarshalerTo. diff --git a/model/gurps/skill.go b/model/gurps/skill.go index 36bcfe49e..6de6cba13 100644 --- a/model/gurps/skill.go +++ b/model/gurps/skill.go @@ -747,12 +747,98 @@ func (s *Skill) CalculateLevel(excludes map[string]bool) Level { s.DefaultedFrom, s.Difficulty, points, s.EncumbrancePenaltyMultiplier) } +// BaseRelativeLevelWithSettings returns the base relative skill level at 0 points, using custom settings if provided. +// +// Mode behavior: +// - When UseSkillModifierAdjustments is false (default): Adjustment mode - adds adjustment values to GURPS defaults +// - When UseSkillModifierAdjustments is true: Override mode - replaces defaults with override values if set +// +// Override mode logic: +// - For Easy: 0 means "use default" (which is 0). Non-zero values override the default. +// NOTE: There is an ambiguity - setting override to 0 cannot distinguish between "use default" and "override to 0". +// This is acceptable since Easy's default is 0, so both interpretations yield the same result. +// - For Average/Hard/VeryHard: 0 means "not set, use default". Values matching the default also use the default. +// Only values that are non-zero AND different from the default will override. +func BaseRelativeLevelWithSettings(diffLevel difficulty.Level, settings *SheetSettings) fxp.Int { + defaultValue := diffLevel.BaseRelativeLevel() + if settings == nil { + return defaultValue + } + + if settings.UseSkillModifierAdjustments { + // Override mode: replace defaults (when toggle is checked) + switch diffLevel { + case difficulty.Easy: + // For Easy, default is 0. If override is explicitly set to non-zero, use that. Otherwise use default 0. + // NOTE: Setting override to 0 cannot distinguish between "use default" and "override to 0", but since + // Easy's default is 0, both interpretations yield the same result. + if settings.EasySkillModifierOverride != 0 { + return settings.EasySkillModifierOverride + } + // 0 means use default (which is also 0 for Easy) + case difficulty.Average: + // For Average, default is -1. If 0, it means "not set", use default. Otherwise use the override value. + if settings.AverageSkillModifierOverride == 0 { + // Not set, use default + } else if settings.AverageSkillModifierOverride != -fxp.One { + // Override value that's not the default + return settings.AverageSkillModifierOverride + } + // Matches default, use default + case difficulty.Hard: + // For Hard, default is -2. If 0, it means "not set", use default. Otherwise use the override value. + if settings.HardSkillModifierOverride == 0 { + // Not set, use default + } else if settings.HardSkillModifierOverride != -fxp.Two { + // Override value that's not the default + return settings.HardSkillModifierOverride + } + // Matches default, use default + case difficulty.VeryHard, difficulty.Wildcard: + // For Very Hard, default is -3. If 0, it means "not set", use default. Otherwise use the override value. + if settings.VeryHardSkillModifierOverride == 0 { + // Not set, use default + } else if settings.VeryHardSkillModifierOverride != -fxp.Three { + // Override value that's not the default + return settings.VeryHardSkillModifierOverride + } + // Matches default, use default + default: + // Unknown difficulty level - fall back to default + return defaultValue + } + // Fall back to defaults if no override was applied + return defaultValue + } else { + // Adjustment mode: add to defaults (default behavior) + switch diffLevel { + case difficulty.Easy: + return defaultValue + settings.EasySkillModifierAdjustment + case difficulty.Average: + return defaultValue + settings.AverageSkillModifierAdjustment + case difficulty.Hard: + return defaultValue + settings.HardSkillModifierAdjustment + case difficulty.VeryHard, difficulty.Wildcard: + return defaultValue + settings.VeryHardSkillModifierAdjustment + default: + return defaultValue + } + } +} + // CalculateSkillLevel returns the calculated level for a skill. func CalculateSkillLevel(e *Entity, name, specialization string, tags []string, def *SkillDefault, attrDiff AttributeDifficulty, points, encumbrancePenaltyMultiplier fxp.Int) Level { var tooltip xbytes.InsertBuffer - relativeLevel := attrDiff.Difficulty.BaseRelativeLevel() - level := e.ResolveAttributeCurrent(attrDiff.Attribute) - if level != fxp.Min { + var settings *SheetSettings + if e != nil { + settings = e.SheetSettings + } + relativeLevel := BaseRelativeLevelWithSettings(attrDiff.Difficulty, settings) + level := fxp.Min + if e != nil { + level = e.ResolveAttributeCurrent(attrDiff.Attribute) + } + if level != fxp.Min && e != nil { if e.SheetSettings.UseHalfStatDefaults { level = level.Div(fxp.Two).Floor() + fxp.Five } @@ -881,8 +967,13 @@ func (s *Skill) bestDefaultWithPoints(excluded *SkillDefault) *SkillDefault { } best := s.bestDefault(excluded) if best != nil { - baseLine := (EntityFromNode(s).ResolveAttributeCurrent(s.Difficulty.Attribute) + - s.Difficulty.Difficulty.BaseRelativeLevel()).Floor() + entity := EntityFromNode(s) + var settings *SheetSettings + if entity != nil { + settings = entity.SheetSettings + } + baseLine := (entity.ResolveAttributeCurrent(s.Difficulty.Attribute) + + BaseRelativeLevelWithSettings(s.Difficulty.Difficulty, settings)).Floor() level := best.Level.Floor() best.AdjLevel = level switch { diff --git a/model/gurps/spell.go b/model/gurps/spell.go index fbc70d10a..5c1ff021b 100644 --- a/model/gurps/spell.go +++ b/model/gurps/spell.go @@ -706,7 +706,11 @@ func (s *Spell) DecrementSkillLevel() { // CalculateSpellLevel returns the calculated spell level. func CalculateSpellLevel(e *Entity, name, powerSource string, colleges, tags []string, attrDiff AttributeDifficulty, pts fxp.Int) Level { var tooltip xbytes.InsertBuffer - relativeLevel := attrDiff.Difficulty.BaseRelativeLevel() + var settings *SheetSettings + if e != nil { + settings = e.SheetSettings + } + relativeLevel := BaseRelativeLevelWithSettings(attrDiff.Difficulty, settings) level := fxp.Min if e != nil { pts = pts.Floor() diff --git a/ux/body_panel.go b/ux/body_panel.go index 2d04f60be..8ad156236 100644 --- a/ux/body_panel.go +++ b/ux/body_panel.go @@ -45,8 +45,9 @@ func NewBodyPanel(entity *gurps.Entity, targetMgr *TargetMgr) *BodyPanel { targetMgr: targetMgr, } p.Self = p + // Layout will be set in addContent based on whether PD column is shown p.SetLayout(&unison.FlexLayout{ - Columns: 8, + Columns: 8, // Default, will be updated in addContent HSpacing: 4, }) p.SetLayoutData(&unison.FlexLayoutData{ @@ -88,6 +89,19 @@ func NewBodyPanel(entity *gurps.Entity, targetMgr *TargetMgr) *BodyPanel { func (p *BodyPanel) addContent(locations *gurps.Body) { p.RemoveAllChildren() + // Update layout columns based on whether PD column is shown + // PD column is shown when UsePassiveDefense is enabled + settings := gurps.SheetSettingsFor(p.entity) + showPD := settings.UsePassiveDefense + columns := 8 + if showPD { + columns = 10 // Add 2 columns for PD (header + spacer) + } + p.SetLayout(&unison.FlexLayout{ + Columns: columns, + HSpacing: 4, + }) + p.AddChild(NewPageHeader(i18n.Text("Roll"), 1)) p.AddChild(unison.NewPanel()) p.AddChild(NewPageHeader(i18n.Text("Location"), 2)) @@ -96,6 +110,13 @@ func (p *BodyPanel) addContent(locations *gurps.Body) { header.Tooltip = newWrappedTooltip(i18n.Text("Damage Resistance for the hit location")) p.AddChild(header) p.AddChild(unison.NewPanel()) + // Add PD column header if enabled + if showPD { + header = NewPageHeader(i18n.Text("PD"), 1) + header.Tooltip = newWrappedTooltip(i18n.Text("Passive Defense for the hit location (GURPS 3e optional rule)")) + p.AddChild(header) + p.AddChild(unison.NewPanel()) + } header = NewPageHeader("", 1) header.Tooltip = newWrappedTooltip(i18n.Text("Notes for the hit location")) baseline := header.Font.Baseline() * 0.8 @@ -216,6 +237,27 @@ func (p *BodyPanel) addTable(bodyType *gurps.Body, depth int) { p.addSeparator() } + // Add PD field if column is enabled (when UsePassiveDefense is on) + showPD := gurps.SheetSettingsFor(p.entity).UsePassiveDefense + if showPD { + pd := NewNonEditablePageFieldCenter(func(f *NonEditablePageField) { + var tooltip xbytes.InsertBuffer + f.SetTitle(location.DisplayPD(p.entity, &tooltip)) + tip := fmt.Sprintf(i18n.Text("The PD (Passive Defense) covering the **%s** hit location"), location.TableName) + if detail := tooltip.String(); detail != "" { + tip += ":" + detail + } + f.Tooltip = newMarkdownTooltip(tip, "") + MarkForLayoutWithinDockable(f) + }) + pd.SetLayoutData(&unison.FlexLayoutData{}) + p.AddChild(pd) + + if i == 0 && depth == 0 { + p.addSeparator() + } + } + title := fmt.Sprintf(i18n.Text("Notes for the **%s** hit location"), location.TableName) notesField := NewStringPageField(p.targetMgr, "body:"+location.ID(), title, func() string { return location.Notes }, func(value string) { location.Notes = value }) @@ -250,7 +292,15 @@ func (p *BodyPanel) Sync() { func (p *BodyPanel) sync(force bool) { locations := gurps.SheetSettingsFor(p.entity).BodyType - if hash := gurps.Hash64(locations); force || hash != p.hash { + settings := gurps.SheetSettingsFor(p.entity) + // Include UsePassiveDefense in hash calculation so panel rebuilds when PD setting changes + hash := gurps.Hash64(locations) + if settings.UsePassiveDefense { + hash = hash*31 + 1 + } else { + hash = hash*31 + 0 + } + if force || hash != p.hash { p.hash = hash p.titledBorder.Title = locations.Name p.addContent(locations) diff --git a/ux/features_panel.go b/ux/features_panel.go index 30ca9d1d6..ee7576915 100644 --- a/ux/features_panel.go +++ b/ux/features_panel.go @@ -106,6 +106,7 @@ func (p *featuresPanel) insertFeaturePanel(index int, f gurps.Feature) { panel, focus = p.createCostReductionPanel(one) case *gurps.DRBonus: panel, focus = p.createDRBonusPanel(one) + // PassiveDefenseBonus is also handled as DRBonus case *gurps.ReactionBonus: panel, focus = p.createReactionBonusPanel(one) case *gurps.SkillBonus: @@ -731,6 +732,8 @@ func (p *featuresPanel) createFeatureForType(featureType feature.Type) gurps.Fea return gurps.NewCostReduction(lastAttributeIDUsed) case feature.DRBonus: bonus = gurps.NewDRBonus() + case feature.PassiveDefenseBonus: + bonus = gurps.NewPassiveDefenseBonus() case feature.ReactionBonus: bonus = gurps.NewReactionBonus() case feature.SkillBonus: diff --git a/ux/sheet_settings_dockable.go b/ux/sheet_settings_dockable.go index a96b72ee6..e0f90d5f2 100644 --- a/ux/sheet_settings_dockable.go +++ b/ux/sheet_settings_dockable.go @@ -29,6 +29,13 @@ import ( var _ GroupedCloser = &sheetSettingsDockable{} +var ( + // SkillModifierFieldMin is the minimum value allowed for skill difficulty modifier fields + SkillModifierFieldMin = fxp.FromInteger(-1000) + // SkillModifierFieldMax is the maximum value allowed for skill difficulty modifier fields + SkillModifierFieldMax = fxp.FromInteger(1000) +) + // EntityPanel defines methods for a panel that can hold an entity. type EntityPanel interface { unison.Paneler @@ -67,6 +74,21 @@ type sheetSettingsDockable struct { bottomMarginField *unison.Field rightMarginField *unison.Field blockLayoutField *unison.Field + useSkillModifierAdjustments *unison.CheckBox + skillModifierOverridePanel *unison.Panel + skillModifierAdjustmentPanel *unison.Panel + easySkillModifierOverrideField *DecimalField + averageSkillModifierOverrideField *DecimalField + hardSkillModifierOverrideField *DecimalField + veryHardSkillModifierOverrideField *DecimalField + easySkillModifierAdjustmentField *DecimalField + averageSkillModifierAdjustmentField *DecimalField + hardSkillModifierAdjustmentField *DecimalField + veryHardSkillModifierAdjustmentField *DecimalField + useBasicMoveForDodge *unison.CheckBox + includeDodgeFlatBonus *unison.CheckBox + usePassiveDefense *unison.CheckBox + dodgeOverrideField *DecimalField } // ShowSheetSettings the Sheet Settings. Pass in nil to edit the defaults or a sheet to edit the sheet's. @@ -120,6 +142,9 @@ func (d *sheetSettingsDockable) initContent(content *unison.Panel) { }) d.createDamageProgression(content) d.createOptions(content) + d.createSkillDifficultyModifiers(content) + d.createDodgeCustomization(content) + d.createPassiveDefense(content) d.createUnitsOfMeasurement(content) d.createWhereToDisplay(content) d.createPageSettings(content) @@ -236,6 +261,245 @@ func (d *sheetSettingsDockable) createOptions(content *unison.Panel) { content.AddChild(panel) } +func (d *sheetSettingsDockable) createSkillDifficultyModifiers(content *unison.Panel) { + s := d.settings() + panel := unison.NewPanel() + panel.SetLayout(&unison.FlexLayout{ + Columns: 1, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + panel.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + d.createHeader(panel, i18n.Text("Skill Difficulty Modifiers"), 1) + + // Toggle between Adjustment (default) and Override modes + d.useSkillModifierAdjustments = d.addCheckBox(panel, i18n.Text("Use overrides instead of adjustments"), + s.UseSkillModifierAdjustments, func() { + d.settings().UseSkillModifierAdjustments = d.useSkillModifierAdjustments.State == check.On + d.updateSkillModifierFieldsVisibility() + d.syncSheet(false) + }) + d.useSkillModifierAdjustments.Tooltip = newWrappedTooltip(i18n.Text("When checked, values completely replace GURPS defaults. When unchecked (default), values are added to the defaults.")) + + // Create wrapper panels for override and adjustment fields + d.skillModifierOverridePanel = unison.NewPanel() + d.skillModifierOverridePanel.SetLayout(&unison.FlexLayout{ + Columns: 2, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + d.skillModifierOverridePanel.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + + d.skillModifierAdjustmentPanel = unison.NewPanel() + d.skillModifierAdjustmentPanel.SetLayout(&unison.FlexLayout{ + Columns: 2, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + d.skillModifierAdjustmentPanel.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + + // Override fields + d.createOverrideFields(d.skillModifierOverridePanel) + // Adjustment fields + d.createAdjustmentFields(d.skillModifierAdjustmentPanel) + + // Set initial visibility before adding panels to parent + d.updateSkillModifierFieldsVisibility() + + // Add the appropriate panel based on current settings + if s.UseSkillModifierAdjustments { + panel.AddChild(d.skillModifierOverridePanel) + } else { + panel.AddChild(d.skillModifierAdjustmentPanel) + } + content.AddChild(panel) +} + +// skillModifierFieldConfig holds configuration for creating a skill modifier field +type skillModifierFieldConfig struct { + label string + tooltip string + getter func() fxp.Int + setter func(fxp.Int) + fieldPtr **DecimalField +} + +func (d *sheetSettingsDockable) createSkillModifierField(panel *unison.Panel, config skillModifierFieldConfig) { + panel.AddChild(NewFieldLeadingLabel(config.label, false)) + field := NewDecimalField(nil, "", config.label, config.getter, config.setter, + SkillModifierFieldMin, SkillModifierFieldMax, true, false) + field.Tooltip = newWrappedTooltip(config.tooltip) + *config.fieldPtr = field + panel.AddChild(field) +} + +func (d *sheetSettingsDockable) createOverrideFields(panel *unison.Panel) { + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Easy (E) Override"), + tooltip: i18n.Text("Override the base relative skill level modifier for Easy skills at 0 points. Leave at 0 to use GURPS default (0, no modifier)."), + getter: func() fxp.Int { return d.settings().EasySkillModifierOverride }, + setter: func(value fxp.Int) { d.settings().EasySkillModifierOverride = value; d.syncSheet(false) }, + fieldPtr: &d.easySkillModifierOverrideField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Average (A) Override"), + tooltip: i18n.Text("Override the base relative skill level modifier for Average skills at 0 points. Leave at 0 to use GURPS default (-1)."), + getter: func() fxp.Int { return d.settings().AverageSkillModifierOverride }, + setter: func(value fxp.Int) { d.settings().AverageSkillModifierOverride = value; d.syncSheet(false) }, + fieldPtr: &d.averageSkillModifierOverrideField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Hard (H) Override"), + tooltip: i18n.Text("Override the base relative skill level modifier for Hard skills at 0 points. Leave at 0 to use GURPS default (-2)."), + getter: func() fxp.Int { return d.settings().HardSkillModifierOverride }, + setter: func(value fxp.Int) { d.settings().HardSkillModifierOverride = value; d.syncSheet(false) }, + fieldPtr: &d.hardSkillModifierOverrideField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Very Hard (VH) Override"), + tooltip: i18n.Text("Override the base relative skill level modifier for Very Hard and Wildcard skills at 0 points. Leave at 0 to use GURPS default (-3)."), + getter: func() fxp.Int { return d.settings().VeryHardSkillModifierOverride }, + setter: func(value fxp.Int) { d.settings().VeryHardSkillModifierOverride = value; d.syncSheet(false) }, + fieldPtr: &d.veryHardSkillModifierOverrideField, + }) +} + +func (d *sheetSettingsDockable) createAdjustmentFields(panel *unison.Panel) { + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Easy (E) Adjustment"), + tooltip: i18n.Text("Adjustment added to the GURPS default for Easy skills (default: 0). Example: +1 makes Easy skills one level better than standard."), + getter: func() fxp.Int { return d.settings().EasySkillModifierAdjustment }, + setter: func(value fxp.Int) { d.settings().EasySkillModifierAdjustment = value; d.syncSheet(false) }, + fieldPtr: &d.easySkillModifierAdjustmentField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Average (A) Adjustment"), + tooltip: i18n.Text("Adjustment added to the GURPS default for Average skills (default: -1). Example: +1 makes Average skills equal to Easy."), + getter: func() fxp.Int { return d.settings().AverageSkillModifierAdjustment }, + setter: func(value fxp.Int) { d.settings().AverageSkillModifierAdjustment = value; d.syncSheet(false) }, + fieldPtr: &d.averageSkillModifierAdjustmentField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Hard (H) Adjustment"), + tooltip: i18n.Text("Adjustment added to the GURPS default for Hard skills (default: -2). Example: -1 makes Hard skills one level worse."), + getter: func() fxp.Int { return d.settings().HardSkillModifierAdjustment }, + setter: func(value fxp.Int) { d.settings().HardSkillModifierAdjustment = value; d.syncSheet(false) }, + fieldPtr: &d.hardSkillModifierAdjustmentField, + }) + + d.createSkillModifierField(panel, skillModifierFieldConfig{ + label: i18n.Text("Very Hard (VH) Adjustment"), + tooltip: i18n.Text("Adjustment added to the GURPS default for Very Hard and Wildcard skills (default: -3). Example: -2 makes Very Hard skills two levels worse."), + getter: func() fxp.Int { return d.settings().VeryHardSkillModifierAdjustment }, + setter: func(value fxp.Int) { d.settings().VeryHardSkillModifierAdjustment = value; d.syncSheet(false) }, + fieldPtr: &d.veryHardSkillModifierAdjustmentField, + }) +} + +func (d *sheetSettingsDockable) updateSkillModifierFieldsVisibility() { + useOverrides := d.settings().UseSkillModifierAdjustments + if d.skillModifierOverridePanel != nil && d.skillModifierAdjustmentPanel != nil { + parent := d.skillModifierOverridePanel.Parent() + if parent != nil { + // Remove both panels + d.skillModifierOverridePanel.RemoveFromParent() + d.skillModifierAdjustmentPanel.RemoveFromParent() + // Add back the appropriate one + // useOverrides=true means show override panel, useOverrides=false means show adjustment panel (default) + if useOverrides { + parent.AddChild(d.skillModifierOverridePanel) + } else { + parent.AddChild(d.skillModifierAdjustmentPanel) + } + parent.MarkForLayoutRecursivelyUpward() + parent.MarkForRedraw() + } + } +} + +func (d *sheetSettingsDockable) createDodgeCustomization(content *unison.Panel) { + s := d.settings() + panel := unison.NewPanel() + panel.SetLayout(&unison.FlexLayout{ + Columns: 1, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + panel.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + d.createHeader(panel, i18n.Text("Dodge Calculation Customization"), 1) + + d.useBasicMoveForDodge = d.addCheckBox(panel, i18n.Text("Use Basic Move instead of Basic Speed for dodge base"), + s.UseBasicMoveForDodge, func() { + d.settings().UseBasicMoveForDodge = d.useBasicMoveForDodge.State == check.On + d.syncSheet(false) + }) + d.useBasicMoveForDodge.Tooltip = newWrappedTooltip(i18n.Text("When checked, dodge is calculated from Basic Move instead of Basic Speed. Standard GURPS 4E uses Basic Speed.")) + + d.includeDodgeFlatBonus = d.addCheckBox(panel, i18n.Text("Include flat +3 bonus in dodge calculation"), + s.IncludeDodgeFlatBonus, func() { + d.settings().IncludeDodgeFlatBonus = d.includeDodgeFlatBonus.State == check.On + d.syncSheet(false) + }) + d.includeDodgeFlatBonus.Tooltip = newWrappedTooltip(i18n.Text("When checked, adds a flat +3 to dodge (standard GURPS 4E). When unchecked, removes this bonus (GURPS 3E style).")) + + // Dodge Override field + label := i18n.Text("Manual Dodge Value") + tooltip := i18n.Text("Optionally set a fixed dodge value that overrides the calculated dodge. Leave at 0 to use the calculated value based on Basic Speed/Move, modifiers, and encumbrance.") + wrapper := unison.NewPanel() + wrapper.SetLayout(&unison.FlexLayout{ + Columns: 2, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + wrapper.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + wrapper.AddChild(NewFieldLeadingLabel(label, false)) + d.dodgeOverrideField = NewDecimalField(nil, "", label, + func() fxp.Int { return d.settings().DodgeOverride }, + func(value fxp.Int) { + d.settings().DodgeOverride = value + d.syncSheet(false) + }, fxp.FromInteger(0), fxp.FromInteger(100), true, false) + d.dodgeOverrideField.Tooltip = newWrappedTooltip(tooltip) + d.dodgeOverrideField.Watermark = i18n.Text("0 = use calculated") + d.dodgeOverrideField.SetLayoutData(&unison.FlexLayoutData{ + HAlign: align.Fill, + HGrab: true, + }) + wrapper.AddChild(d.dodgeOverrideField) + panel.AddChild(wrapper) + + content.AddChild(panel) +} + +func (d *sheetSettingsDockable) createPassiveDefense(content *unison.Panel) { + s := d.settings() + panel := unison.NewPanel() + panel.SetLayout(&unison.FlexLayout{ + Columns: 1, + HSpacing: unison.StdHSpacing, + VSpacing: unison.StdVSpacing, + }) + panel.SetLayoutData(&unison.FlexLayoutData{HAlign: align.Fill}) + d.createHeader(panel, i18n.Text("Passive Defense (PD) - GURPS 3e Optional Rule"), 1) + + // Passive Defense (PD) as optional rule (GURPS 3e) + d.usePassiveDefense = d.addCheckBox(panel, i18n.Text("Use Passive Defense (PD)"), + s.UsePassiveDefense, func() { + d.settings().UsePassiveDefense = d.usePassiveDefense.State == check.On + // Automatically show PD column when PD is enabled + d.settings().ShowPDColumn = d.usePassiveDefense.State == check.On + d.syncSheet(true) // Full rebuild needed to show/hide PD column in body panel + }) + d.usePassiveDefense.Tooltip = newWrappedTooltip(i18n.Text("When enabled, PD applies when an active defense (Dodge/Parry/Block) fails. PD is added to the failed defense roll only if armor with PD covers the hit location. PD is location-based, just like DR. This is a GURPS 3e optional rule that was removed in 4e. Enabling this will also show a PD column in the body type hit location table.")) + + content.AddChild(panel) +} + func (d *sheetSettingsDockable) addCheckBox(panel *unison.Panel, title string, checked bool, onClick func()) *unison.CheckBox { checkbox := unison.NewCheckBox() checkbox.SetTitle(title) @@ -499,6 +763,10 @@ func (d *sheetSettingsDockable) sync() { d.useHalfStatDefaults.State = check.FromBool(s.UseHalfStatDefaults) d.useModifyDicePlusAdds.State = check.FromBool(s.UseModifyingDicePlusAdds) d.excludeUnspentPointsFromTotal.State = check.FromBool(s.ExcludeUnspentPointsFromTotal) + if d.useSkillModifierAdjustments != nil { + d.useSkillModifierAdjustments.State = check.FromBool(s.UseSkillModifierAdjustments) + d.updateSkillModifierFieldsVisibility() + } d.lengthUnitsPopup.Select(s.DefaultLengthUnits) d.weightUnitsPopup.Select(s.DefaultWeightUnits) d.userDescDisplayPopup.Select(s.UserDescriptionDisplay) @@ -512,6 +780,30 @@ func (d *sheetSettingsDockable) sync() { d.bottomMarginField.SetText(s.Page.BottomMargin.String()) d.rightMarginField.SetText(s.Page.RightMargin.String()) d.blockLayoutField.SetText(s.BlockLayout.String()) + if d.easySkillModifierOverrideField != nil { + d.easySkillModifierOverrideField.Sync() + d.averageSkillModifierOverrideField.Sync() + d.hardSkillModifierOverrideField.Sync() + d.veryHardSkillModifierOverrideField.Sync() + d.easySkillModifierAdjustmentField.Sync() + d.averageSkillModifierAdjustmentField.Sync() + d.hardSkillModifierAdjustmentField.Sync() + d.veryHardSkillModifierAdjustmentField.Sync() + } + if d.useBasicMoveForDodge != nil { + d.useBasicMoveForDodge.State = check.FromBool(s.UseBasicMoveForDodge) + d.includeDodgeFlatBonus.State = check.FromBool(s.IncludeDodgeFlatBonus) + } + if d.usePassiveDefense != nil { + d.usePassiveDefense.State = check.FromBool(s.UsePassiveDefense) + // Sync ShowPDColumn to match UsePassiveDefense (they should always be in sync) + if s.ShowPDColumn != s.UsePassiveDefense { + s.ShowPDColumn = s.UsePassiveDefense + } + } + if d.dodgeOverrideField != nil { + d.dodgeOverrideField.Sync() + } d.MarkForRedraw() }