Domain Reuse Guidelines
Rules for when to extract shared domain logic in A House Divided, and when to keep logic inline. Complements shared-utility-guidelines.md which covers generic (non-domain) helpers.
Core principle#
Domain semantics trump DRY purity. Duplicating a formula once is cheaper than a wrong abstraction that hides what the game is doing. Only extract when the behavior is genuinely identical and a single name makes the code clearer.
When to extract domain logic#
Extract when all three conditions hold:
- Same formula, same meaning. The code is not just structurally similar — it represents the same game concept. Two different
Math.max(0, Math.min(100, x))calls with different domain bounds are not "the same." - A bug fix must apply everywhere. If the formula drifts between call sites, game behavior becomes inconsistent in ways players would notice (e.g., NPP compliance calculated differently in bill voting vs leadership voting).
- A domain-specific name clarifies intent.
calculateComplianceChance(npp)is better than inliningloyalty * 0.7 + (1 - stubbornness) * 0.3three times, because the name communicates the game concept.
When to keep logic inline#
- Different fallback chains. Policy option lookup in
billEnactment.ts(id → effectDirection → center fallback) differs frompolicyEffects.ts(id → effectDirection, no center fallback) andbillEnrichment.ts(id only). Forcing these into one helper would hide the intentional differences. - Different clamp bounds. Metric clamping uses different min/max per context (
0–100for approval,−10–+10for momentum,−20–+20for demographic modifiers). A genericclamp()hides the domain-specific bounds. - Different
MIN_CHANGE_THRESHOLDvalues. Policy effects use0.001, demographics use0.0001. These are tuned independently. - One-off validation guards. A "candidate must be in home state" check appears once in election entry — no need to extract.
Established reuse patterns#
These are already extracted and should be used consistently:
| Domain concept | Canonical location | Notes |
| --------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------- | ------ | -------- | ----------------------- |
| NPP compliance chance | calculateComplianceChance() in | loyalty * 0.7 + (1 - stubbornness) * 0.3 — use this, don't inline |
| NPP whip resolution | resolveWhipForNPP() in | State-over-national priority, optional country collision prevention |
| NPP country detection | getNPPCountry() in | countryId field → fallback to getCountryIdFromStateId(homeState) |
| Ideology alignment score | alignmentScore() in | max(0, 100 - | econ_diff | \* 5 - | soc_diff | \* 5) — 0 to 100 scale |
| Leadership voting constants | PARTY_MATCH_BONUS, REAL_PLAYER_BONUS in | Shared by speaker recalculation and turn-based leadership voting |
| NPP base/whipped vote | calculateBaseVote(), calculateWhippedVote() in | Ideology + personality + whip compliance |
| Bill passage check | didPass() in | Simple majority: votesFor > votesAgainst |
| Policy decay | applyPolicyDecay(), getPolicyDecayFactor() in | Exponential decay with configurable tau |
| Half-life decay | applyHalfLifeDecay() in | initial * 0.5^(turns / halfLife) |
| Policy contribution | calculatePolicyContribution() in | Normalized strength × weight × max effect × scope × sign |
| Demographic appeal | calcAppeal() in | Shared by vote distribution and poll calculations |
| NPI normalization | normalizeNPI() in | Sqrt political influence scaling, capped at 1.0 once PI/NPI ≥ 100 |
| Country config | getCountryConfig() in | Never hardcode "US", "UK", etc. |
| Party org constants | | Momentum rates, cap weights, election bonuses |
Anti-patterns#
Don't: Extract a generic clamp(value, min, max)#
The inline Math.max(min, Math.min(max, value)) pattern appears 30+ times with domain-specific bounds. A generic clamp would:
- Hide what the bounds are (are we clamping approval? momentum? metrics?)
- Not actually reduce bugs (the pattern itself never breaks; wrong bounds do)
- Add an import for a one-liner
Don't: Unify policy option lookups#
billEnactment.ts, policyEffects.ts, and billEnrichment.ts each look up policy options with intentionally different fallback chains. A shared findPolicyOption() would need mode flags that obscure what each caller actually wants.
Don't: Extract "threshold calculation" helpers#
The Math.ceil((2/3) * seats) pattern for veto overrides appears in federal and state bill lifecycle with different chamber structures. The federal version counts House and Senate independently; the state version uses a single chamber. A shared helper would need flags for this distinction.
Don't: Create a generic "lifecycle phase" abstraction#
Elections, bills, and campaigns all have phase transitions, but the states, triggers, and side effects differ fundamentally. A shared LifecycleManager<T> would add indirection without clarifying any individual system.
Adding new reuse#
When you identify a candidate for extraction:
- Verify it's used identically in 2+ places (search the codebase, don't assume).
- Name it after the domain concept, not the implementation (
calculateComplianceChance, notweightedAverage). - Place it in the module closest to its consumers (e.g., NPP voting logic stays in
src/lib/turn/npp/, notsrc/lib/utils/). - Add a test file next to the extracted function.
- Update this document with the new canonical location.
- Update all call sites — don't leave orphaned inline copies.
Audit log#
| Date | Change | Files affected |
|---|---|---|
| 2026-03-23 | Extracted resolveWhipForNPP from bill/leadership voting |
whipResolution.ts, billVoting.ts, leadershipVoting.ts |
| 2026-03-23 | Consolidated alignmentScore and leadership constants |
speakerRecalculation.ts → imports from leadershipVoting.ts |
| 2026-03-23 | Used calculateComplianceChance in leadership whip resolution |
leadershipVoting.ts → imports from nppVoteLogic.ts |