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:
- Player submits an offer (credits per unit).
- 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.
- Station replies with one of:
- Accept — transaction executes at the agreed price; session ends.
- Counteroffer — station proposes a price between the player's offer and its fair price; player may accept, counter, or walk.
- Reject — offer is too far from acceptable range; player loses one round but session continues.
- 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¶
- Player submits a single free-form persuasive statement (max 280 chars) per round, up to 2 rounds.
- The narrative-haggling service builds a prompt containing:
- The station's personality (type, faction, current trader mood, memory of this player).
- Recent player ship state (hull integrity, last sectors visited, cargo manifest, faction standing).
- The commodity, quantity, posted price, and the implied target price the player is asking for.
- A summary of prior haggling lines this player has used at this station.
- 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. - 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):
- Validate JSON shape; if malformed, emit
narrative_llm_malformedevent and fall back tonumericalevaluation for the round. - Clamp
applied_multiplierto[0.80, 1.20]. - Re-derive the score-weighted multiplier from
scoresand the rubric weights; if it diverges fromapplied_multiplierby more than 0.02, override with the recomputed value (the LLM doesn't get to skip the rubric). - Filter
trader_replyfor prompt-injection echoes (any token from the system prompt or context payload that the player wrote verbatim into their submission is stripped). - Persist the round to
NarrativeHagglingRound(sub-table ofHagglingSession); 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 |
Status footer¶
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.
Related¶
- ./trading.md — main trading flow, pricing model, faction modifiers (haggling stacks on top).
- ../gameplay/aria-companion.md — ARIA's market-intelligence layer adjacent to haggling (ARIA can suggest target prices but does not negotiate on the player's behalf).
- ../../DATA_MODELS/jsonb-schema.md —
Station.trader_personalityshape. - ../../OPERATIONS/aria.md — ARIA security model; the narrative-haggling LLM call is gated through it.