> For the complete documentation index, see [llms.txt](https://gyrinx.gitbook.io/gyrinx/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://gyrinx.gitbook.io/gyrinx/n23-edition/fighter-cost-system-reference.md).

# Fighter Cost System Reference

This document provides a comprehensive reference for the fighter cost calculation system in Gyrinx, including how costs are calculated, overridden, and displayed throughout the application.

> **See also:** [Fighter Cost System Design Guide](/gyrinx/n23-edition/fighter-cost-system-design.md) for the design philosophy and architectural decisions behind this system.

## Overview

The fighter cost system calculates the total cost of a fighter by combining:

* Base fighter cost
* Equipment costs
* Weapon profile costs
* Weapon accessory costs
* Equipment upgrade costs
* Campaign advancement costs

## Cost Calculation Flow

### 1. List Total Cost

The total cost of a list is calculated by:

```
Total List Cost = Sum of all fighter costs + Current credits
```

Implementation: `List.cost_int()` in `n23/core/models/list/list.py`

### 2. Fighter Cost

Each fighter's cost is calculated as:

```
Fighter Cost = Base Cost + Advancement Cost + Sum of Equipment Assignment Costs
```

Implementation: `ListFighter.cost_int()` in `n23/core/models/list/fighter.py`

### 3. Base Fighter Cost

The base cost follows this priority hierarchy:

1. User override: `ListFighter.cost_override` (if set)
2. Child fighter: 0 (if fighter is child of another)
3. House override: `ContentFighterHouseOverride.cost` (if exists)
4. Content fighter: `ContentFighter.base_cost`

Implementation: `ListFighter._base_cost_int` property in `n23/core/models/list/fighter.py`

### 4. Equipment Assignment Cost

Each equipment assignment's cost is:

```
Assignment Cost = Base Equipment Cost + Profile Costs + Accessory Costs + Upgrade Costs
```

Or if total cost override is set:

```
Assignment Cost = total_cost_override
```

Implementation: `ListFighterEquipmentAssignment.cost_int()` in `n23/core/models/list/assignment.py`

### 5. Equipment Base Cost

Equipment base cost priority:

1. Assignment override: `ListFighterEquipmentAssignment.cost_override`
2. Linked equipment: 0 (if equipment is linked/child)
3. Fighter equipment list: `ContentFighterEquipmentListItem.cost`
4. Base equipment: `ContentEquipment.cost`

Implementation: `ListFighterEquipmentAssignment._equipment_cost_with_override()` in `n23/core/models/list/assignment.py`

### 6. Weapon Profile Cost

Profile costs follow this priority:

1. Default assignment: 0 (if profile is part of default assignment)
2. Fighter equipment list: `ContentFighterEquipmentListItem.cost` (with weapon\_profile)
3. Base profile: `ContentWeaponProfile.cost`

Implementation: `ListFighterEquipmentAssignment._profile_cost_with_override_for_profile()` in `n23/core/models/list/assignment.py`

### 7. Weapon Accessory Cost

Accessory costs follow this priority:

1. Default assignment: 0 (if accessory is part of default assignment)
2. Fighter equipment list: `ContentFighterEquipmentListWeaponAccessory.cost`
3. Base accessory: `ContentWeaponAccessory.cost`

Implementation: `ListFighterEquipmentAssignment._accessory_cost_with_override()` in `n23/core/models/list/assignment.py`

### 8. Equipment Upgrade Cost

Upgrade costs depend on the equipment's upgrade mode:

* Multi mode: Individual upgrade cost
* Single mode: Cumulative cost (sum of all upgrades up to selected position)

Implementation: `ContentEquipmentUpgrade.cost_int()` in `n23/content/models/equipment.py`

### 9. Campaign Advancement Cost

In campaign mode, advancements increase fighter cost:

```
Advancement Cost = Sum of all cost_increase values from ListFighterAdvancement
```

## Cost Override Models

### ContentFighterHouseOverride

Allows specific fighters to have different costs when added to specific houses.

Fields:

* `fighter`: The ContentFighter
* `house`: The ContentHouse
* `cost`: The override cost (nullable)

### ContentFighterEquipmentListItem

Defines fighter-specific costs for equipment and weapon profiles.

Fields:

* `fighter`: The ContentFighter
* `equipment`: The ContentEquipment
* `weapon_profile`: Optional specific profile
* `cost`: The override cost

### ContentFighterEquipmentListWeaponAccessory

Defines fighter-specific costs for weapon accessories.

Fields:

* `fighter`: The ContentFighter
* `weapon_accessory`: The ContentWeaponAccessory
* `cost`: The override cost

## Legacy Fighter System

The legacy fighter system supports the Venators' Gang Legacy rule, allowing them to use equipment from another house's fighter:

1. Legacy fighter: Set via `ListFighter.legacy_content_fighter`
2. Equipment list fighter: Property that returns legacy fighter if set, otherwise regular content fighter
3. Cost overrides: Check equipment\_list\_fighter for proper legacy support

Implementation: `ListFighter.equipment_list_fighter` property

## Special Cost Rules

### Zero Cost Items

* Stash fighters: Must have `base_cost = 0`
* Default assignments: Always cost 0
* Linked equipment: Child equipment in linked relationships cost 0
* Child fighters: Fighters created by equipment profiles cost 0

### Cost Display

All costs are displayed with the ¢ symbol using `format_cost_display()`:

* Regular display: "50¢"
* With sign: "+50¢" or "-50¢"
* Zero: "0¢"

## Virtual Equipment Assignment

The `VirtualListFighterEquipmentAssignment` class provides a unified interface for both:

* Direct equipment assignments (`ListFighterEquipmentAssignment`)
* Default equipment assignments (`ContentFighterDefaultAssignment`)

This allows consistent cost calculation regardless of assignment type.

## Facts System API

The facts system provides fast O(1) reads of cached cost values. Each cost-bearing model has database fields (`rating_current`, `dirty`) and methods to access them.

### Facts Dataclasses

Cached values are returned as immutable dataclasses (defined in `n23/core/models/facts.py`):

```python
@dataclass(frozen=True)
class AssignmentFacts:
    rating: int

@dataclass(frozen=True)
class FighterFacts:
    rating: int

@dataclass(frozen=True)
class ListFacts:
    rating: int   # Sum of active fighter costs
    stash: int    # Stash fighter cost
    credits: int  # Liquid credits

    @property
    def wealth(self) -> int:
        return self.rating + self.stash + self.credits
```

### Cache Fields

Each level in the hierarchy has cached fields:

| Model                            | Fields                                                        |
| -------------------------------- | ------------------------------------------------------------- |
| `List`                           | `rating_current`, `stash_current`, `credits_current`, `dirty` |
| `ListFighter`                    | `rating_current`, `dirty`                                     |
| `ListFighterEquipmentAssignment` | `rating_current`, `dirty`                                     |

### Facts Methods

Every cost-bearing model provides three methods:

#### facts() - Fast Cached Read

Returns cached values as a facts dataclass, or `None` if cache is stale:

```python
def facts(self) -> Optional[ListFacts]:
    """O(1) read from cached fields. Returns None if dirty=True."""
    if self.dirty:
        return None
    return ListFacts(
        rating=self.rating_current,
        stash=self.stash_current,
        credits=self.credits_current,
    )
```

#### facts\_from\_db() - Full Recalculation

Recalculates from database and optionally updates cache:

```python
def facts_from_db(self, update: bool = True) -> ListFacts:
    """
    Recalculate facts from database.

    If update=True: saves rating_current, clears dirty flag.
    Uses QuerySet.update() to bypass signals.
    """
```

### When to Use Each Method

| Scenario           | Method                               | Why                                           |
| ------------------ | ------------------------------------ | --------------------------------------------- |
| Display in views   | Persisted fields via display methods | O(1) read; dirty lists show last-good numbers |
| Object creation    | `create_with_facts()`                | Atomic creation with cache                    |
| Handler operations | Don't call - use propagation         | Handlers use incremental updates              |
| Manual refresh     | `facts_from_db(update=True)`         | Full recalculation                            |

### The create\_with\_facts() Pattern

For atomic object creation with correct initial cache state:

```python
# ListManager, ListFighterManager, ListFighterEquipmentAssignmentManager
def create_with_facts(self, **kwargs):
    """Create object and immediately calculate facts."""
    obj = self.create(**kwargs)  # dirty=True by default
    obj.facts_from_db(update=True)  # Now dirty=False
    return obj
```

### Display Methods

List display methods read the persisted cache fields directly — dirty or not. A dirty list shows its last-good numbers until the write-time heal (`set_dirty` enqueues `refresh_list_facts` on commit) or a detail-page view (`get_clean_list_or_404`) recomputes them:

```python
# List
def cost_display(self):
    return format_cost_display(self.wealth_current)

# Similar for rating_display, stash_fighter_cost_display

# ListFighter keeps a live fallback so a card never shows a number known
# to be stale:
def cost_display(self):
    facts = self.facts()
    if facts is not None:
        return format_cost_display(facts.rating)
    return format_cost_display(self.cost_int_cached)
```

### Dirty Flag Management

The `dirty` flag indicates cached values may be stale:

```python
# Mark as dirty (cascades upward)
assignment.set_dirty(save=True)  # Also marks fighter and list dirty
fighter.set_dirty(save=True)     # Also marks list dirty
lst.set_dirty(save=True)         # Only marks list dirty

# List.set_dirty(save=True) also enqueues a background heal
# (refresh_list_facts) on commit — only for the caller whose UPDATE
# actually flips the row clean->dirty. Bulk dirty-marking helpers
# bypass this; the content-cost-change task heals those lists itself.

# Cleared by:
# - facts_from_db(update=True)
# - Propagation functions (propagate_from_*)
```

## Cost Propagation

For write operations, the propagation system incrementally updates cached values rather than recalculating:

```python
from n23.core.cost.propagation import propagate_from_assignment, Delta

# After adding equipment worth 10 credits
propagate_from_assignment(assignment, Delta(delta=10, list=lst))
# Updates: assignment.rating_current += 10, fighter.rating_current += 10
```

> **See also:** [Cost Handler Development Guide](/gyrinx/n23-edition/handler-development.md) for detailed handler patterns.

## Database Queries Optimization

The system uses several optimizations:

* `select_related()` for foreign keys
* `prefetch_related()` for many-to-many relationships
* Cached properties to avoid repeated calculations
* Annotation with cost overrides in querysets

### Prefetching

Display methods need no prefetch — they read persisted fields. The prefetches below optimise related-data access and `latest_action` reads:

```python
# Latest-action prefetch (staff debug header, maintenance tooling)
lists = List.objects.with_latest_actions()

# Full prefetch for detail views
lst = List.objects.with_related_data().get(pk=pk)

# Fighter-level prefetch
fighters = ListFighter.objects.with_related_data()
```

## Common Usage Patterns

### Getting a List's Total Wealth

```python
lst = List.objects.get(pk=list_id)
wealth = lst.wealth_current  # rating_current + stash_current + credits_current
```

### Getting a Fighter's Total Cost

```python
fighter = ListFighter.objects.get(id=fighter_id)
facts = fighter.facts()
if facts:
    total_cost = facts.rating  # Fast cached read
else:
    total_cost = fighter.cost_int()  # Full calculation
```

### Getting Equipment Cost with Override

```python
assignment = ListFighterEquipmentAssignment.objects.get(id=assignment_id)
facts = assignment.facts()
if facts:
    cost = facts.rating  # Fast cached read
else:
    cost = assignment.cost_int()  # Includes all overrides and sub-costs
```

### Checking for Cost Overrides

```python
# Fighter level
if fighter.has_cost_override:
    # User has manually set the cost

# Assignment level
if assignment.has_total_cost_override():
    # Total cost is manually overridden
```

## Error Handling

The system includes validation for:

* Negative costs (prevented in model `clean()` methods)
* Invalid cost strings (non-integer values)
* Circular equipment links
* Multiple fighter profiles for same equipment

## Cost Mixins

The cost system provides reusable mixins to standardize cost calculation behavior across models:

### CostMixin

`gyrinx.models.CostMixin` provides common cost methods for models with cost fields:

```python
class CostMixin(models.Model):
    """
    Mixin for models that have cost calculation logic.

    Attributes
    ----------
    cost_field_name : str
        The name of the field that stores the cost. Defaults to 'cost'.
        Can be overridden in subclasses if the field has a different name.
    """

    cost_field_name = "cost"

    def cost_int(self):
        """Returns the integer cost of this item."""

    def cost_display(self, show_sign=False):
        """Returns a readable cost string with currency symbol."""
```

Key features:

* Handles both integer and string cost fields
* Converts string costs to integers when possible
* Returns 0 for empty or non-numeric values
* Provides formatted display with ¢ symbol
* Supports custom field names via `cost_field_name` attribute

Usage example:

```python
class MyModel(CostMixin, models.Model):
    price = models.IntegerField()
    cost_field_name = "price"  # Override default field name
```

### FighterCostMixin

`gyrinx.models.FighterCostMixin` extends `CostMixin` for models with fighter-specific cost overrides:

```python
class FighterCostMixin(CostMixin):
    """
    Extended cost mixin for models that have fighter-specific cost overrides.
    """

    def cost_for_fighter_int(self):
        """Returns the fighter-specific cost if available."""
```

Key features:

* Inherits all functionality from `CostMixin`
* Adds `cost_for_fighter_int()` method
* Expects models to be annotated with `cost_for_fighter` attribute
* Raises `AttributeError` if annotation is missing

Usage with querysets:

```python
# Annotate queryset with fighter-specific costs
equipment = ContentEquipment.objects.with_cost_for_fighter(fighter)
cost = equipment.first().cost_for_fighter_int()
```

### Models Using Cost Mixins

The following models use these mixins:

* `ContentEquipment` (FighterCostMixin)
* `ContentWeaponProfile` (FighterCostMixin) - with custom `cost_display()` logic
* `ContentWeaponAccessory` (FighterCostMixin)
* `ContentEquipmentUpgrade` (CostMixin) - with custom `cost_int()` logic
* `ContentFighterEquipmentListItem` (CostMixin)
* `ContentFighterEquipmentListWeaponAccessory` (CostMixin)
* `ContentFighterEquipmentListUpgrade` (CostMixin)
* `ContentFighterDefaultAssignment` (CostMixin)

### Special Behaviors

Some models override the mixin methods for custom behavior:

ContentWeaponProfile:

* `cost_display()` returns empty for standard profiles (no name)
* Shows "+" prefix for named profiles with positive costs

ContentEquipmentUpgrade:

* `cost_int()` implements cumulative costs in SINGLE mode
* Sums all upgrades up to current position

## Testing

Key test files for the cost system:

* `n23/core/tests/test_cost_display.py` - Cost formatting tests
* `n23/core/tests/test_models_core.py` - Core cost calculation tests
* `n23/core/tests/test_assignments.py` - Equipment assignment cost tests
* `n23/content/tests/test_cost_methods.py` - Comprehensive tests for all cost mixins


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://gyrinx.gitbook.io/gyrinx/n23-edition/fighter-cost-system-reference.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
