snapkitty
hardware
erlang
File size: 7,226 Bytes
b8a03fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import { clamp } from './deterministic.js';
import { emitWorldEvent, findRequired, trimHistory } from './state-utils.js';
import type { CommandResult, EntityId, InventoryStack, MarketListingState, UniverseState } from './types.js';

export class EconomySystem {
  constructor(private state: UniverseState) {}

  replaceState(state: UniverseState): void {
    this.state = state;
  }

  trade(marketId: EntityId, commodityId: EntityId, quantity: number, side: 'buy' | 'sell'): CommandResult {
    if (!Number.isInteger(quantity) || quantity <= 0) return this.failure('Trade quantity must be a positive integer.');
    const market = findRequired(this.state.economy.markets, marketId, 'Market');
    if (this.state.player.locationId !== market.locationId) return this.failure(`Player must be present at ${market.name} to trade.`);
    const listing = market.listings.find((item) => item.commodityId === commodityId);
    if (!listing) return this.failure(`Commodity '${commodityId}' is not listed at '${marketId}'.`);
    const commodity = findRequired(this.state.economy.commodities, commodityId, 'Commodity');
    const faction = findRequired(this.state.factions, market.factionId, 'Market faction');
    if (market.sanctions.includes(this.state.player.id) || faction.sanctions.includes(this.state.player.id)) return this.failure('Player is sanctioned by this market.');
    const unitPrice = side === 'buy' ? listing.sellPrice : listing.buyPrice;
    const gross = unitPrice * quantity;
    const tariff = gross * market.tariffRate;
    const total = side === 'buy' ? gross + tariff : gross - tariff;
    if (side === 'buy') {
      if (listing.inventory < quantity) return this.failure(`${market.name} does not have enough ${commodity.name}.`);
      if (this.state.player.credits < total) return this.failure('Insufficient credits.');
      const cargoMass = this.state.player.inventory.reduce((sum, item) => sum + item.massKg * item.quantity, 0);
      if (cargoMass + commodity.unitMassKg * quantity > 200) return this.failure('Personal inventory mass limit exceeded.');
      this.state.player.credits -= total;
      market.credits += total;
      listing.inventory -= quantity;
      this.addPlayerCommodity(commodityId, commodity.name, commodity.unitMassKg, quantity);
    } else {
      const stack = this.state.player.inventory.find((item) => item.itemId === commodityId);
      if (!stack || stack.quantity < quantity) return this.failure(`Player does not possess ${quantity} ${commodity.name}.`);
      if (market.credits < total) return this.failure(`${market.name} cannot fund this purchase.`);
      stack.quantity -= quantity;
      this.state.player.inventory = this.state.player.inventory.filter((item) => item.quantity > 0);
      this.state.player.credits += total;
      market.credits -= total;
      listing.inventory += quantity;
    }
    this.repriceListing(listing, commodity.basePrice, market.tariffRate);
    const event = emitWorldEvent(this.state, 'market-trade', this.state.player.id, market.id, `${side === 'buy' ? 'Bought' : 'Sold'} ${quantity} ${commodity.name} at ${market.name}.`, { data: { commodityId, quantity, side, total } });
    return { ok: true, message: event.summary, eventIds: [event.id] };
  }

  getPrice(marketId: EntityId, commodityId: EntityId): { buy: number; sell: number; shortage: boolean } {
    const market = findRequired(this.state.economy.markets, marketId, 'Market');
    const listing = market.listings.find((candidate) => candidate.commodityId === commodityId);
    if (!listing) throw new Error(`Commodity '${commodityId}' is not listed at '${marketId}'.`);
    return { buy: listing.buyPrice, sell: listing.sellPrice, shortage: listing.shortage };
  }

  tick(deltaMs: number): void {
    const elapsedHours = deltaMs / 3_600_000;
    if (elapsedHours <= 0) return;
    for (const market of this.state.economy.markets) {
      for (const listing of market.listings) {
        listing.inventory = Math.max(0, listing.inventory + (listing.productionPerHour - listing.consumptionPerHour) * elapsedHours);
        const commodity = findRequired(this.state.economy.commodities, listing.commodityId, 'Commodity');
        this.repriceListing(listing, commodity.basePrice, market.tariffRate);
      }
    }
    for (const route of this.state.economy.tradeRoutes) {
      const traffic = this.state.traffic.find((vessel) => vessel.id === route.assignedTrafficId);
      if (traffic?.status === 'distress') continue;
      route.travelProgress += elapsedHours * (0.2 / Math.max(0.05, 1 + route.risk));
      if (route.travelProgress < 1) continue;
      route.travelProgress %= 1;
      const origin = findRequired(this.state.economy.markets, route.originMarketId, 'Origin market');
      const destination = findRequired(this.state.economy.markets, route.destinationMarketId, 'Destination market');
      const originListing = origin.listings.find((listing) => listing.commodityId === route.commodityId);
      const destinationListing = destination.listings.find((listing) => listing.commodityId === route.commodityId);
      const moved = Math.min(route.cargoUnits, originListing?.inventory ?? 0);
      if (originListing) originListing.inventory -= moved;
      if (destinationListing) destinationListing.inventory += moved;
      emitWorldEvent(this.state, 'trade-route-delivery', route.assignedTrafficId, destination.id, `Trade route delivered ${moved} units of ${route.commodityId}.`, { data: { commodityId: route.commodityId, quantity: moved } });
    }
    if (this.state.tick - this.state.economy.lastUpdateTick >= 100) {
      for (const commodity of this.state.economy.commodities) {
        const average = this.state.economy.markets.reduce((sum, market) => sum + (market.listings.find((listing) => listing.commodityId === commodity.id)?.sellPrice ?? commodity.basePrice), 0) / this.state.economy.markets.length;
        const history = this.state.economy.priceHistory[commodity.id] ?? [];
        history.push(Number(average.toFixed(2)));
        trimHistory(history, 120);
        this.state.economy.priceHistory[commodity.id] = history;
      }
      this.state.economy.lastUpdateTick = this.state.tick;
    }
  }

  private repriceListing(listing: MarketListingState, basePrice: number, tariffRate: number): void {
    const ratio = listing.targetInventory / Math.max(1, listing.inventory);
    const scarcityMultiplier = clamp(0.55 + ratio * 0.45, 0.6, 3.5);
    listing.shortage = listing.inventory < listing.targetInventory * 0.5;
    listing.buyPrice = Number((basePrice * scarcityMultiplier * (1 - tariffRate * 0.5)).toFixed(2));
    listing.sellPrice = Number((basePrice * scarcityMultiplier * (1.08 + tariffRate)).toFixed(2));
  }

  private addPlayerCommodity(itemId: EntityId, name: string, massKg: number, quantity: number): void {
    const existing = this.state.player.inventory.find((item) => item.itemId === itemId);
    if (existing) existing.quantity += quantity;
    else {
      const stack: InventoryStack = { itemId, name, quantity, massKg, tags: ['commodity'] };
      this.state.player.inventory.push(stack);
    }
  }

  private failure(message: string): CommandResult {
    return { ok: false, message, eventIds: [] };
  }
}