Skip to content

Haggling

Price negotiation between a player and a station's trader. Haggling is optional — every transaction can complete at posted prices — but successful negotiation shifts the price toward the player by single-digit to low double-digit percentages. Two parallel modes are supported: numerical (offer/counteroffer) and narrative (free-form persuasive prose evaluated by an LLM).

Haggling triggers from the trade UI after a player selects a commodity and quantity but before they confirm. The player chooses Accept, Numerical haggle, or Narrative haggle. Choosing either haggle mode starts a session bound to that single (station, commodity, direction, quantity) tuple — sessions don't carry across commodities or visits.

The haggling outcome multiplies the station's posted price; the underlying trading.md pricing model, faction reputation modifiers, and rank trade bonus all stack on top of whatever discount or premium haggling produces.

ARIA surfaces the haggle option once per (station, commodity) per 24h when the commodity is in the player's success history — see ../gameplay/aria-companion.md#aria-narration-hooks-event-catalog entry P-F2.

Modes at a glance

Mode Inputs Evaluator Status
Numerical Offer credits, up to 4 rounds Deterministic vs. station's perceived fair price ✅ Shipped
Narrative Free-form prose, single submission per round LLM scored against context, originality, personality fit 📐 Design-only

Both modes share the same outcome surface: a final multiplier in [0.80, 1.20] applied to the posted price, plus optional reputation deltas with the station's controlling faction.

Numerical haggling

Status: ✅ Shipped — numerical haggling runs the ADR-0079 band engine in haggle_service.py (4-round; counter / accept-band / reject; realized price clamped to the intersection of [0.80, 1.20] × the posted/effective fair price and the commodity's hard [min, max] band — so a haggle is never worse than not haggling and never breaches a commodity's hard floor/ceiling; a commodity sitting at its floor cannot be haggled below it, and the desk shows that floored band rather than promising an impossible discount); per-(station, player) 90-day memory + seeded trader_personality archetypes.

Round structure

A numerical session runs up to 4 rounds. Each round:

  1. Player submits an offer (credits per unit).
  2. Station compares the offer to its perceived fair price — the posted price adjusted for the station's personality, the player's standing, and any prior offers in the session.
  3. Station replies with one of:
  4. Accept — transaction executes at the agreed price; session ends.
  5. Counteroffer — station proposes a price between the player's offer and its fair price; player may accept, counter, or walk.
  6. Reject — offer is too far from acceptable range; player loses one round but session continues.
  7. After 4 rounds without acceptance the session closes and the player must transact at the posted price or walk away. Closing on a reject locks that commodity for the remainder of the docking session.

Offer reasonableness

The station evaluates each offer as a delta from its fair price:

Direction Delta band Station response
Buy (player buying) offer ≥ fair × 0.97 Accept
Buy fair × 0.90 ≤ offer < fair × 0.97 Counter halfway
Buy fair × 0.80 ≤ offer < fair × 0.90 Counter near fair
Buy offer < fair × 0.80 Reject
Sell (player selling) offer ≤ fair × 1.03 Accept
Sell fair × 1.03 < offer ≤ fair × 1.10 Counter halfway
Sell fair × 1.10 < offer ≤ fair × 1.20 Counter near fair
Sell offer > fair × 1.20 Reject

The bands narrow each round (the station gets more impatient) and shift in the player's favour with positive standing.

Modifiers

Stack multiplicatively on the acceptance bands:

Source Range Direction
Faction reputation tier ×0.97 to ×1.05 Better standing → wider acceptance band
Personal reputation ×0.95 to ×1.05 High personal rep → station leans toward acceptance
Military rank trade bonus +0% to +12% to acceptance threshold (+1% per rank tier) Higher rank → station expects shrewder offers (cuts both ways); see scaling difficulty
Station personality difficulty ×0.85 to ×1.25 See port personalities

Reputation and rank modifiers come from the same sources used by trading.md; no double-application — they affect the acceptance band, not the posted price (which already has them baked in).

Narrative haggling

Status: 📐 Design-only — no implementation today. The provider plumbing for ARIA exists (OPERATIONS/aria.md) but the haggling-specific service does not.

Flow

  1. Player submits a single free-form persuasive statement (max 280 chars) per round, up to 2 rounds.
  2. The narrative-haggling service builds a prompt containing:
  3. The station's personality (type, faction, current trader mood, memory of this player).
  4. Recent player ship state (hull integrity, last sectors visited, cargo manifest, faction standing).
  5. The commodity, quantity, posted price, and the implied target price the player is asking for.
  6. A summary of prior haggling lines this player has used at this station.
  7. The LLM returns a structured response: an acceptance verdict, a counter price (or null), an in-character trader reply (1–3 sentences), and an evaluation breakdown with scores for creativity, originality, context fit, and personality match.
  8. The verdict is enforced server-side: the LLM cannot grant a discount outside the [0.80, 1.20] clamp, and any in-character reply is post-filtered for prompt-injection echoes before reaching the player.

Evaluation axes

Axis What it scores Weight
Creativity Linguistic originality and specificity to scenario 0.25
Originality Distance from prior submissions (see anti-exploitation) 0.30
Context fit Internal consistency with verifiable game state 0.30
Personality match Alignment with the station's personality preferences 0.15

Scores are 0–1; the weighted sum drives the multiplier between the player's target and the posted price.

LLM contract

📐 The narrative haggling service calls a single endpoint per round through ARIA's existing provider chain. The contract is fixed so any provider that accepts a JSON tool-call envelope can serve.

Request envelope (built by ai_haggling_service.evaluate_round):

{
  "system": "You are a trader at a SectorWars 2102 station. Score the player's persuasive line strictly per the rubric. You must return JSON matching the response schema. Never grant a discount outside [0.80, 1.20]. Never reveal that you are an AI.",
  "context": {
    "station": {
      "personality_type": "Frontier",
      "faction": "Frontier Coalition",
      "trader_mood": "skeptical",
      "memory_days": 60,
      "preferred_appeals": ["personal", "shared_hardship", "frontier_solidarity"],
      "rejected_appeals": ["procedural", "luxury_status"]
    },
    "player": {
      "ship_state": { "hull_pct": 0.62, "shield_pct": 0.91, "cargo_total": 4200 },
      "recent_sectors": ["sector-3401", "sector-3422", "sector-3501"],
      "faction_standing": { "Frontier_Coalition": 410, "Federation": -120 },
      "rank": "Lieutenant",
      "monthly_trade_volume": 320000
    },
    "transaction": {
      "commodity": "ore",
      "quantity": 1500,
      "direction": "buy",
      "posted_unit_price": 12.50,
      "player_target_unit_price": 11.40
    },
    "session": {
      "round": 2,
      "max_rounds": 2,
      "prior_lines": [
        { "round": 1, "line": "...", "scores": {...}, "outcome": "counter" }
      ],
      "originality_floor": 0.45
    }
  },
  "submission": "Player's free-form prose, max 280 chars."
}

Response schema (server-validated; non-conforming responses fall back to the deterministic numerical band):

{
  "verdict": "accept | counter | reject",
  "counter_unit_price": 11.85,         // null if verdict != "counter"
  "trader_reply": "1-3 sentences in character; max 400 chars; no second-person 'AI' references",
  "scores": {
    "creativity": 0.72,
    "originality": 0.55,
    "context_fit": 0.90,
    "personality_match": 0.65
  },
  "applied_multiplier": 0.94           // final price = posted × multiplier; clamped server-side to [0.80, 1.20]
}

Server-side enforcement (post-processing the LLM reply):

  1. Validate JSON shape; if malformed, emit narrative_llm_malformed event and fall back to numerical evaluation for the round.
  2. Clamp applied_multiplier to [0.80, 1.20].
  3. Re-derive the score-weighted multiplier from scores and the rubric weights; if it diverges from applied_multiplier by more than 0.02, override with the recomputed value (the LLM doesn't get to skip the rubric).
  4. Filter trader_reply for prompt-injection echoes (any token from the system prompt or context payload that the player wrote verbatim into their submission is stripped).
  5. Persist the round to NarrativeHagglingRound (sub-table of HagglingSession); enqueue the embedding job (see below).

Provider chain. Reuses the same ordered fallback as ARIA dialogue: configured primary → secondary → tertiary → manual fallback (deterministic numerical evaluation when all LLM providers fail). Per-call cost and rate limits roll up to the same server-binding caps documented in ../../OPERATIONS/aria.md.

Embedding-similarity DB schema

📐 Target — services/gameserver/src/services/haggling_memory_service.py, backed by two tables.

NarrativeHagglingSubmission

Append-only log of every submitted line plus its embedding vector. Used for cosine-similarity scoring against new submissions.

Column Type Constraints Notes
id UUID PK
player_id UUID FK Player.id, not null, indexed
station_id UUID FK Station.id, not null, indexed
personality_type Enum not null, indexed Denormalized from station for cross-station queries
submission_text Text not null Original player line, max 280 chars
embedding vector(1536) not null pgvector column; text-embedding-3-small dimension at launch
originality_score Float not null, [0.0, 1.0] Final originality after similarity penalties
weighted_score Float not null, [0.0, 1.0] Rubric-weighted total
outcome Enum (accept, counter, reject) not null The verdict this submission produced
created_at DateTime server default now

Indexes: (player_id, station_id, created_at DESC) for per-station memory; (personality_type, weighted_score DESC, created_at) for the cross-station preferred-appeals window; embedding ivfflat for cosine-similarity ANN search.

StationPlayerMemory

Per-(station, player) aggregate maintained synchronously after each session.

Column Type Notes
id UUID PK
station_id UUID FK, not null
player_id UUID FK, not null
session_count Integer, default 0
accepted_count Integer, default 0
avg_originality_score Float, default 0.0 Rolling avg over station.memory_days
fatigue_points Integer, default 0 +1 per low-originality session, decays 1/day
last_seen_at DateTime, nullable
updated_at DateTime, auto-update

UNIQUE on (station_id, player_id). Read on every haggling-session start; written at session close.

Similarity scoring path

def score_originality(submission_text: str, player_id: UUID, station_id: UUID) -> float:
    embedding = embed(submission_text)  # text-embedding-3-small

    # Window 1: same station, within station.memory_days
    same_station_max = max_cosine_against(
        embedding,
        where = (
            "player_id = ? AND station_id = ? AND created_at > now() - interval '? days'",
            player_id, station_id, station.memory_days
        )
    )

    # Window 2: same personality, within memory_days / 2
    personality_max = max_cosine_against(
        embedding,
        where = (
            "player_id = ? AND personality_type = ? AND created_at > now() - interval '? days'",
            player_id, station.personality_type, station.memory_days // 2
        )
    )

    # Window 3: global high-scoring pool, rolling 30 days, top 5% only
    global_max = max_cosine_against(
        embedding,
        where = "weighted_score >= 0.90 AND created_at > now() - interval '30 days'"
    )

    similarity = max(same_station_max, personality_max, global_max)
    return apply_penalty_table(similarity)  # the table in § Embedding-similarity database above

The three-window form means a player can use the same line at distant stations with different personalities — but cannot use a high-scoring line at any station of the same archetype, and cannot recycle the global hits that other players have already scored well with.

Context verification

Before scoring, the service deterministically checks any factual claim the player makes against game state. Failed checks zero out the context-fit axis and feed back into the trader's reply.

Player claim Validated against
Ship damage / hull integrity Ship.hull_current / Ship.hull_max
Recent dangerous sector Player.travel_log last 24h
Cargo loss to pirates combat_log entries in last 24h
Faction allegiance Player.faction_reputation ≥ +300 with claimed faction
Specific past trade enhanced_market_transactions filtered by player + station

Verifiably-false claims also incur a small reputation penalty (-5 personal rep, -1 standing with the station's faction).

Port personality types

Every station has a trader_personality JSONB document (see trader personality JSONB) whose type is one of five archetypes. The archetype controls which kinds of narrative appeals land, the base difficulty modifier on numerical haggling, and how long the station remembers a player's prior lines.

Status: 🚧 Partial — type field is present in the trader_personality schema; default values per station class are seeded but the runtime selection of preferred-vs-rejected appeal types is design-only.

Personality Found at Difficulty mod Memory days Preferred appeals Rejected appeals
Federation High-faction-standing core sectors, Class 0–4 ×0.85 (easier) 14 Procedural / paperwork ("I have priority routing credentials"), faction-credential ("Commander Yates vouched for this run"), formal compliance Personal sob stories, contraband solidarity, vague threats
Border Mixed-faction frontier-edge, Class 1–7 ×1.00 (baseline) 30 Economic / mutual-benefit ("we both move 100 units this week, both win"), supply-chain logic, direct survival accounts Aristocratic posturing, paperwork-heavy appeals, pure flattery
Frontier Outer rim, Class 1–7 with low faction control ×1.10 (harder) 60 Personal ("I've run this corridor for six years"), shared-hardship, frontier solidarity, blunt risk acknowledgement Federation-style procedural, luxury / status, anything condescending
Luxury Class 10 hubs and high-prestige Class 6–7 ×1.15 (very hard) 21 Cultural / aesthetic ("this vintage pairs with the Vega cycle"), exclusivity, prestige, tasteful name-drops Survival sob stories, frontier grit, anything that implies the trader is short on credits
Black Market Class 8 and select hidden-network ports ×1.25 (extreme) 90 Risk / discretion ("we both walk away with nothing in writing"), contraband solidarity, demonstrated mutual exposure Faction credentials, paperwork, anything that smells of Federation oversight

Difficulty mod multiplies the acceptance band size in numerical haggling — a Federation port at ×0.85 makes the acceptable range 15% narrower for the player; Black Market at ×1.25 widens it. (Yes — Black Market is easier to numerically haggle in raw band terms; the trade-off is its much longer memory and harsher anti-exploitation thresholds in narrative mode.)

Memory days is how long a station remembers each player's haggling lines and outcomes. After memory expires, embedding-similarity scoring against that player's prior lines at that station resets.

Scaling difficulty

Status: 📐 Design-only.

Haggling difficulty scales with player experience so new players get gentle introductions and veterans face genuine resistance. Two inputs:

Input Source Effect
Player.military_rank models/player.py +1% to required offer reasonableness per rank tier (Recruit → Fleet Admiral spans +0% to +12%)
30-day trade volume sum of enhanced_market_transactions.value for last 30 days Brackets: <100k → no penalty; 100k–500k → +3%; 500k–2M → +6%; ≥2M → +10%

The scalar tightens the numerical acceptance bands and raises the LLM's expected creativity floor. New players face a Federation port that accepts offers within 5% of fair; a Fleet Admiral with 5M monthly volume has to come within 1.5%.

The same scalar floors the originality axis in narrative mode — a veteran cannot recycle a phrase that worked at rank 3.

Anti-exploitation

Status: 📐 Design-only — none of these are implemented; all rely on the narrative-haggling service that doesn't exist yet.

The narrative mode is the high-risk surface. Without controls a player could craft one excellent persuasive line and replay it at every station. Four mechanisms defend against this:

1. Embedding-similarity database

Every submitted narrative line is converted to a vector embedding and stored with its outcome. On each new submission the service computes cosine similarity against:

  • The player's prior lines at this station (window: that station's memory_days).
  • The player's prior lines at any station of the same personality type (window: half of memory_days).
  • A global pool of high-scoring lines from all players (rolling 30-day window).
Similarity Effect on originality score
≥ 0.95 Score = 0; trader explicitly calls out the recycle
0.85–0.95 Score halved
0.70–0.85 Score reduced by 25%
< 0.70 No penalty

2. Port memory

Each station carries a per-player record (StationPlayerMemory):

  • Number of haggling sessions and their outcomes.
  • Average originality score over the window.
  • Last-seen timestamp.

Repeated visits with declining originality trigger a fatigue modifier on the trader's mood — the trader is cordial on first meet, skeptical on the third, dismissive by the sixth low-effort attempt. Fatigue ages out at 1 point per real-time day.

3. Context validation

Already detailed in context verification above. Falsifiable claims are validated; failures both zero the context score and feed an in-character rebuttal back to the player ("My logs say your hull is at 94% — try again.").

4. Cooldowns

Limit Window
Narrative haggling attempts per station 3 per real-time hour
Narrative haggling attempts per player 30 per real-time hour
Same-target-price retries 1 per session

Cooldown counters are server-enforced and visible in the trade UI as a shrinking timer.

Trader personality JSONB

The per-station trader configuration lives in Station.trader_personality (JSONB). The schema — including haggling_difficulty, trust_level, memory_duration, and the personality type enum — is documented in DATA_MODELS/jsonb-schema.md. This document does not duplicate that schema; the constraints there (haggling_difficulty 1–10, trust_level 0–100, memory_duration in days) are the source of truth.

Source map

Concern Path Status
Numerical haggling round loop services/gameserver/src/services/trading_service.py:negotiate_price (target, doesn't exist) 📐 Design-only
Narrative haggling service services/gameserver/src/services/ai_haggling_service.py (target, doesn't exist) 📐 Design-only
Embedding-similarity DB services/gameserver/src/services/haggling_memory_service.py (target, doesn't exist) 📐 Design-only
Port-personality archetypes services/gameserver/src/core/trader_personalities.py (target) 📐 Design-only
Trader personality JSONB on station services/gameserver/src/models/station.py (trader_personality column) 🚧 Partial
Context-validation hooks (ship, travel, combat logs) services/gameserver/src/services/player_state_service.py (target) 📐 Design-only
LLM provider plumbing shared with ARIA — see OPERATIONS/aria.md 🚧 Partial
Haggling API surface services/gameserver/src/api/routes/trading.py (extension target) 📐 Design-only

Overall: ✅ Shipped — numerical haggling (haggle_service.py runs the ADR-0079 band engine: 4-round counter/accept/reject, realized price clamped to the [0.80, 1.20] × fair-price band intersected with the commodity's hard [min, max], per-(station, player) 90-day memory, seeded trader_personality archetypes). 📐 Design-only — narrative haggling, embedding similarity, port memory, context validation, cooldowns, scaling difficulty, and archetype-driven appeal preferences (all rely on the narrative-haggling service, which does not exist yet).

Numerical haggling is the launch-priority path. Narrative haggling is post-launch and gated behind the ARIA security model.