Dope Wars wave 1: momentum market, sparklines, market impact, info, rep, investments, chase bribe, shark favors
This commit is contained in:
@@ -24,7 +24,7 @@ final class DopeWarsScene: SKScene {
|
||||
case pickTrade(DopeWarsSim.Item) // BUY or SELL this row?
|
||||
case amount(AmountMode)
|
||||
case keypad(AmountMode) // type the amount directly
|
||||
case ride, shark, bank, confirmEnd, gameOver
|
||||
case ride, shark, bank, contact, investments, favor, bronxMenu, confirmEnd, gameOver
|
||||
}
|
||||
|
||||
private var mode: Mode = .ready
|
||||
@@ -66,6 +66,7 @@ final class DopeWarsScene: SKScene {
|
||||
private var rowHeld: [SKLabelNode] = []
|
||||
private var rowArrows: [SKLabelNode] = []
|
||||
private var rowBacks: [SKSpriteNode] = []
|
||||
private var rowSparks: [SKShapeNode] = []
|
||||
private var lastWalletShown = Int.min
|
||||
|
||||
private var pillNodes: [String: SKShapeNode] = [:]
|
||||
@@ -345,7 +346,7 @@ final class DopeWarsScene: SKScene {
|
||||
boardGroup.zPosition = 5
|
||||
addChild(boardGroup)
|
||||
rowNames.removeAll(); rowPrices.removeAll(); rowHeld.removeAll()
|
||||
rowArrows.removeAll(); rowBacks.removeAll()
|
||||
rowArrows.removeAll(); rowBacks.removeAll(); rowSparks.removeAll()
|
||||
|
||||
let hudBottom = size.height - topInset - 82
|
||||
let barTop = bottomInset + 88
|
||||
@@ -431,6 +432,19 @@ final class DopeWarsScene: SKScene {
|
||||
arrow.zPosition = 2
|
||||
boardGroup.addChild(arrow)
|
||||
rowArrows.append(arrow)
|
||||
|
||||
// 7-day price sparkline, centered in the row's open middle.
|
||||
let sparkW: CGFloat = 92, sparkH: CGFloat = 18
|
||||
let spark = SKShapeNode()
|
||||
spark.fillColor = .clear
|
||||
spark.strokeColor = Palette.mint.withAlphaComponent(0.55)
|
||||
spark.lineWidth = 1.5
|
||||
spark.lineCap = .round
|
||||
spark.lineJoin = .round
|
||||
spark.position = CGPoint(x: boardRect.midX, y: rowY - 2)
|
||||
spark.zPosition = 2
|
||||
boardGroup.addChild(spark)
|
||||
rowSparks.append(spark)
|
||||
}
|
||||
|
||||
statusLine.removeFromParent()
|
||||
@@ -458,7 +472,7 @@ final class DopeWarsScene: SKScene {
|
||||
("act.subway", "SUBWAY", Palette.mint),
|
||||
("act.shark", "SHARK", Palette.amber),
|
||||
("act.bank", "BANK", Palette.cyan),
|
||||
("act.end", "END", Palette.white40),
|
||||
(sim.inBronx ? "act.invest" : "act.end", sim.inBronx ? "DEAL" : "END", sim.inBronx ? Palette.violet : Palette.white40),
|
||||
]
|
||||
let gap: CGFloat = 10
|
||||
let sideMargin: CGFloat = 16
|
||||
@@ -540,13 +554,17 @@ final class DopeWarsScene: SKScene {
|
||||
} else {
|
||||
rowArrows[i].text = ""
|
||||
}
|
||||
drawSpark(rowSparks[i], values: sim.spark[i])
|
||||
}
|
||||
|
||||
var status: [String] = []
|
||||
if sim.guns > 0 { status.append("GUNS \(sim.guns)") }
|
||||
if sim.wounds > 0 { status.append("WOUNDS \(sim.wounds)/50") }
|
||||
if sim.hasFence { status.append("FENCE") }
|
||||
if sim.hasProtection { status.append("PROTECTED") }
|
||||
if sim.forecast != nil { status.append("FORECAST") }
|
||||
if status.isEmpty { // keep the line inside the card
|
||||
status.append(sim.inBronx ? "LOAN SHARK & BANK AVAILABLE" : "SHARK & BANK ARE IN THE BRONX")
|
||||
status.append(sim.inBronx ? "SHARK · BANK · CONTACT" : "SHARK & BANK ARE IN THE BRONX")
|
||||
}
|
||||
statusLine.text = status.joined(separator: " · ")
|
||||
statusLine.fontColor = sim.wounds >= 30 ? Palette.red : Palette.white70
|
||||
@@ -575,6 +593,28 @@ final class DopeWarsScene: SKScene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a row's last-N price history as a tiny line chart (mint if the
|
||||
/// trend is up over the window, red if down).
|
||||
private func drawSpark(_ node: SKShapeNode, values: [Int]) {
|
||||
guard values.count >= 2 else { node.path = nil; return }
|
||||
let w: CGFloat = 92, h: CGFloat = 18
|
||||
let lo = values.min() ?? 0, hi = values.max() ?? 1
|
||||
let range = CGFloat(max(hi - lo, 1))
|
||||
let stepX = w / CGFloat(values.count - 1)
|
||||
var pts: [CGPoint] = []
|
||||
for (i, v) in values.enumerated() {
|
||||
let x = -w / 2 + stepX * CGFloat(i)
|
||||
let y = -h / 2 + (CGFloat(v - lo) / range) * h
|
||||
pts.append(CGPoint(x: x, y: y))
|
||||
}
|
||||
let path = CGMutablePath()
|
||||
path.addLines(between: pts)
|
||||
node.path = path
|
||||
node.strokeColor = (values.last ?? 0) >= (values.first ?? 0)
|
||||
? Palette.mint.withAlphaComponent(0.55)
|
||||
: Palette.red.withAlphaComponent(0.55)
|
||||
}
|
||||
|
||||
// MARK: - Mode machine
|
||||
|
||||
private func setMode(_ new: Mode) {
|
||||
@@ -636,6 +676,10 @@ final class DopeWarsScene: SKScene {
|
||||
case .ride: buildRideSheet()
|
||||
case .shark: buildSharkSheet()
|
||||
case .bank: buildBankSheet()
|
||||
case .contact: buildContactSheet()
|
||||
case .investments: buildInvestmentsSheet()
|
||||
case .favor: buildFavorSheet()
|
||||
case .bronxMenu: buildBronxMenuSheet()
|
||||
case .confirmEnd: buildConfirmEndSheet()
|
||||
case .gameOver: buildGameOverSheet()
|
||||
}
|
||||
@@ -737,11 +781,11 @@ final class DopeWarsScene: SKScene {
|
||||
let rules = [
|
||||
chosenLen == 0 ? "No clock — deal until they get you."
|
||||
: "\(chosenLen) days, then the month ends and you're scored.",
|
||||
"You owe the shark $5,500. Debt grows 10% a day.",
|
||||
"A ride burns a day. Every borough prices its own way.",
|
||||
"Your trench coat holds 100 units.",
|
||||
"50+ units in the coat draws Officer Hardass.",
|
||||
"The Bronx is home: loan shark + 6%-a-day bank.",
|
||||
"Prices trend — read the 7-day sparkline, buy the dip.",
|
||||
"Big trades move the local price. Don't get greedy.",
|
||||
"You owe the shark $5,500. Stay under his cap for good terms.",
|
||||
"The Bronx: shark, bank, a paid contact, and investments.",
|
||||
"50+ units in the coat draws Officer Hardass — bribe, fight, or run.",
|
||||
chosenLen == 0 ? "Endless runs are for fun — no score is posted."
|
||||
: "Score = final net worth (cash + bank − debt).",
|
||||
]
|
||||
@@ -834,11 +878,16 @@ final class DopeWarsScene: SKScene {
|
||||
x: rect.maxX - 30, y: y, align: .right)
|
||||
}
|
||||
|
||||
let bw = (rect.width - 60) / 2
|
||||
let bw = (rect.width - 68) / 3
|
||||
let by = rect.minY + 22
|
||||
let affordBribe = sim.wallet >= sim.bribeCost
|
||||
sheetButton("chase.run", "RUN", color: Palette.mint,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 22, width: bw, height: 56))
|
||||
rect: CGRect(x: rect.minX + 22, y: by, width: bw, height: 56))
|
||||
sheetButton("chase.fight", "FIGHT", color: Palette.red,
|
||||
rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 22, width: bw, height: 56))
|
||||
rect: CGRect(x: rect.minX + 22 + bw + 12, y: by, width: bw, height: 56))
|
||||
sheetButton("chase.bribe", "BRIBE", color: affordBribe ? Palette.amber : Palette.white40,
|
||||
rect: CGRect(x: rect.maxX - 22 - bw, y: by, width: bw, height: 40), fontSize: 12,
|
||||
sub: money(sim.bribeCost))
|
||||
}
|
||||
|
||||
private func buildPickTradeSheet(_ item: DopeWarsSim.Item) {
|
||||
@@ -1038,8 +1087,10 @@ final class DopeWarsScene: SKScene {
|
||||
let rect = sheetCard(height: 268)
|
||||
sheetLabel("LOAN SHARK", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||
x: rect.midX, y: rect.maxY - 32)
|
||||
sheetLabel("Debt \(money(sim.debt)) · Cash \(money(sim.wallet))",
|
||||
font: "Menlo-Bold", size: 13, color: Palette.red, x: rect.midX, y: rect.maxY - 62)
|
||||
let standing = sim.inGoodStanding ? " · GOOD TERMS (\(sim.sharkRate == 0.07 ? "7%" : "10%"))" : ""
|
||||
sheetLabel("Debt \(money(sim.debt))\(standing) · Cash \(money(sim.wallet))",
|
||||
font: "Menlo-Bold", size: 12.5, color: Palette.red, x: rect.midX, y: rect.maxY - 62,
|
||||
maxWidth: rect.width - 40)
|
||||
let bw = rect.width - 44
|
||||
sheetButton("shark.repay", "REPAY DEBT", color: Palette.mint,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.midY - 8, width: bw, height: 48))
|
||||
@@ -1065,6 +1116,92 @@ final class DopeWarsScene: SKScene {
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13)
|
||||
}
|
||||
|
||||
private func buildContactSheet() {
|
||||
dimLayer(0.5)
|
||||
let rect = sheetCard(height: 360)
|
||||
sheetLabel("THE CONTACT", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||
x: rect.midX, y: rect.maxY - 32)
|
||||
sheetLabel("Tomorrow's price on one product, 70% accurate · \(money(DopeWarsSim.forecastCost))",
|
||||
font: "Menlo", size: 11, color: Palette.white70, x: rect.midX, y: rect.maxY - 58,
|
||||
maxWidth: rect.width - 40)
|
||||
sheetLabel("CASH \(money(sim.wallet))", font: "Menlo-Bold", size: 12,
|
||||
color: sim.wallet >= DopeWarsSim.forecastCost ? Palette.mint : Palette.red,
|
||||
x: rect.midX, y: rect.maxY - 80)
|
||||
|
||||
let bw = (rect.width - 64) / 3
|
||||
let by = rect.midY - 20
|
||||
for i in 0..<6 {
|
||||
let item = DopeWarsSim.Item(rawValue: i)!
|
||||
let x = rect.minX + 22 + (bw + 10) * CGFloat(i)
|
||||
let afford = sim.wallet >= DopeWarsSim.forecastCost
|
||||
sheetButton("contact.\(i)", sim.displayName(item).uppercased(),
|
||||
color: afford ? Palette.cyan : Palette.white40,
|
||||
rect: CGRect(x: x, y: by, width: bw, height: 46), fontSize: 11)
|
||||
}
|
||||
sheetButton("sheet.cancel", "CANCEL", color: Palette.white40,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: rect.width - 44, height: 40), fontSize: 13)
|
||||
}
|
||||
|
||||
private func buildInvestmentsSheet() {
|
||||
dimLayer(0.5)
|
||||
let rect = sheetCard(height: 320)
|
||||
sheetLabel("INVESTMENTS", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||
x: rect.midX, y: rect.maxY - 32)
|
||||
sheetLabel("CASH \(money(sim.wallet))", font: "Menlo-Bold", size: 12, color: Palette.mint,
|
||||
x: rect.midX, y: rect.maxY - 58)
|
||||
let bw = rect.width - 44
|
||||
for (i, inv) in DopeWarsSim.Investment.allCases.enumerated() {
|
||||
let y = rect.maxY - 92 - CGFloat(i) * 62
|
||||
let owned = sim.boughtInvestments.contains(inv)
|
||||
let afford = sim.canBuyInvestment(inv)
|
||||
let title = owned ? "\(inv.name) (OWNED)" : "\(inv.name) · \(money(inv.cost))"
|
||||
let btn = sheetButton("inv.\(inv.rawValue)", title,
|
||||
color: owned ? Palette.white40 : (afford ? Palette.mint : Palette.white40),
|
||||
rect: CGRect(x: rect.minX + 22, y: y, width: bw, height: 44),
|
||||
fontSize: 12, sub: inv.blurb)
|
||||
if owned { btn.alpha = 0.5 }
|
||||
}
|
||||
sheetButton("sheet.cancel", "CANCEL", color: Palette.white40,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13)
|
||||
}
|
||||
|
||||
private func buildBronxMenuSheet() {
|
||||
dimLayer(0.5)
|
||||
let rect = sheetCard(height: 300)
|
||||
sheetLabel("BRONX", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||
x: rect.midX, y: rect.maxY - 32)
|
||||
sheetLabel("Home turf — what do you want to do?", size: 13, color: Palette.white70,
|
||||
x: rect.midX, y: rect.maxY - 60)
|
||||
let bw = rect.width - 44
|
||||
sheetButton("bronx.contact", "THE CONTACT (FORECAST)", color: Palette.cyan,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.midY - 24, width: bw, height: 48), fontSize: 13)
|
||||
sheetButton("bronx.invest", "INVESTMENTS", color: Palette.violet,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.midY - 82, width: bw, height: 48), fontSize: 13)
|
||||
sheetButton("bronx.end", "END RUN", color: Palette.red,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.midY - 140, width: bw, height: 44), fontSize: 13)
|
||||
sheetButton("sheet.cancel", "CANCEL", color: Palette.white40,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13)
|
||||
}
|
||||
|
||||
private func buildFavorSheet() {
|
||||
guard let f = sim.favor else { return }
|
||||
dimLayer(0.55)
|
||||
let rect = sheetCard(height: 280)
|
||||
sheetLabel("A FAVOR", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||
x: rect.midX, y: rect.maxY - 36)
|
||||
let name = sim.displayName(DopeWarsSim.Item(rawValue: f.slot)!).uppercased()
|
||||
sheetLabel("The shark wants \(f.unitCount) units of \(name) moved to \(f.destination.name.uppercased()).",
|
||||
size: 14, color: .white, x: rect.midX, y: rect.maxY - 74, maxWidth: rect.width - 44)
|
||||
let cost = sim.price[f.slot] * f.unitCount
|
||||
sheetLabel("Costs you \(money(cost)) to front · no pay · raises your heat",
|
||||
font: "Menlo", size: 11, color: Palette.amber, x: rect.midX, y: rect.maxY - 104)
|
||||
let bw = (rect.width - 60) / 2
|
||||
sheetButton("favor.yes", "DO IT", color: Palette.mint,
|
||||
rect: CGRect(x: rect.minX + 22, y: rect.minY + 22, width: bw, height: 50))
|
||||
sheetButton("favor.no", "REFUSE", color: Palette.red,
|
||||
rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 22, width: bw, height: 50))
|
||||
}
|
||||
|
||||
private func buildConfirmEndSheet() {
|
||||
dimLayer(0.55)
|
||||
let rect = sheetCard(height: 210)
|
||||
@@ -1169,7 +1306,12 @@ final class DopeWarsScene: SKScene {
|
||||
if let key = hitKey(at: p) {
|
||||
Haptics.impact(.medium, intensity: 0.8)
|
||||
let before = sim.price
|
||||
if key == "chase.run" { sim.chaseRun() } else { sim.chaseFight() }
|
||||
switch key {
|
||||
case "chase.run": sim.chaseRun()
|
||||
case "chase.fight": sim.chaseFight()
|
||||
case "chase.bribe": sim.chaseBribe()
|
||||
default: break
|
||||
}
|
||||
if sim.price != before { notePriceMovement(from: before) } // escape reroll
|
||||
syncFromSim()
|
||||
}
|
||||
@@ -1219,6 +1361,44 @@ final class DopeWarsScene: SKScene {
|
||||
default: break
|
||||
}
|
||||
|
||||
case .contact:
|
||||
if let key = hitKey(at: p) {
|
||||
if key == "sheet.cancel" { setMode(.board); Haptics.selection(); return }
|
||||
if key.hasPrefix("contact."), let i = Int(key.dropFirst(8)),
|
||||
let item = DopeWarsSim.Item(rawValue: i) {
|
||||
Haptics.selection()
|
||||
sim.buyForecast(for: item)
|
||||
syncFromSim()
|
||||
}
|
||||
}
|
||||
|
||||
case .investments:
|
||||
if let key = hitKey(at: p) {
|
||||
if key == "sheet.cancel" { setMode(.board); Haptics.selection(); return }
|
||||
if key.hasPrefix("inv."), let i = Int(key.dropFirst(4)),
|
||||
let inv = DopeWarsSim.Investment(rawValue: i) {
|
||||
Haptics.selection()
|
||||
sim.buyInvestment(inv)
|
||||
syncFromSim()
|
||||
}
|
||||
}
|
||||
|
||||
case .favor:
|
||||
switch hitKey(at: p) {
|
||||
case "favor.yes": Haptics.impact(.medium, intensity: 0.7); sim.acceptFavor(); syncFromSim()
|
||||
case "favor.no": Haptics.selection(); sim.refuseFavor(); syncFromSim()
|
||||
default: break
|
||||
}
|
||||
|
||||
case .bronxMenu:
|
||||
switch hitKey(at: p) {
|
||||
case "bronx.contact": Haptics.selection(); setMode(.contact)
|
||||
case "bronx.invest": Haptics.selection(); setMode(.investments)
|
||||
case "bronx.end": Haptics.selection(); setMode(.confirmEnd)
|
||||
case "sheet.cancel": Haptics.selection(); setMode(.board)
|
||||
default: break
|
||||
}
|
||||
|
||||
case .confirmEnd:
|
||||
switch hitKey(at: p) {
|
||||
case "end.yes":
|
||||
@@ -1239,6 +1419,8 @@ final class DopeWarsScene: SKScene {
|
||||
}
|
||||
|
||||
private func handleBoardTap(_ p: CGPoint) {
|
||||
// An open shark favor jumps straight to its accept/refuse card.
|
||||
if sim.favor != nil { Haptics.impact(.medium, intensity: 0.6); setMode(.favor); return }
|
||||
for i in 0..<6 where rowRect(i).contains(p) {
|
||||
guard let item = DopeWarsSim.Item(rawValue: i) else { continue }
|
||||
Haptics.selection()
|
||||
@@ -1256,7 +1438,11 @@ final class DopeWarsScene: SKScene {
|
||||
if sim.inBronx { setMode(.bank) } else { cardQueue.append("Your bank branch is in the Bronx…"); advanceCards() }
|
||||
return
|
||||
}
|
||||
if actionRect(3).contains(p) { Haptics.selection(); setMode(.confirmEnd); return }
|
||||
if actionRect(3).contains(p) {
|
||||
Haptics.selection()
|
||||
setMode(sim.inBronx ? .bronxMenu : .confirmEnd)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAmountTap(_ p: CGPoint, _ am: AmountMode) {
|
||||
|
||||
@@ -48,6 +48,38 @@ import Foundation
|
||||
/// ending if they put you down.
|
||||
/// - **Month lengths**: 30/60/90 days, or endless (no clock, no leaderboard;
|
||||
/// debt and bank interest cap at $999,999,999,999 so long runs stay sane).
|
||||
///
|
||||
/// Remake extensions, wave 1 — the market becomes something you trade *with*:
|
||||
/// - **Price momentum + mean-reversion**: each day a product's price carries
|
||||
/// part of yesterday's move (trend inertia) while drifting back toward a
|
||||
/// per-borough anchor, so markets trend and the 7-day sparkline is real
|
||||
/// signal. Event spikes (lab bust, cartel flood) are injected as shocks and
|
||||
/// decay over a few days instead of resetting one day. One rand() per slot
|
||||
/// per day keeps the seeded sequence stable.
|
||||
/// - **Price history**: the last 7 days per product (at the current borough)
|
||||
/// are kept so the board can draw a sparkline.
|
||||
/// - **Market impact**: a big buy/sell nudges today's local price against you
|
||||
/// (supply/demand) — you can move the needle, and greedy all-ins get punished.
|
||||
/// - **Paid information**: the Bronx contact sells a 1-day, 70%-accurate
|
||||
/// forecast for a product; the free street rumor stays but is noisier.
|
||||
/// - **Reputation**: a hidden 0…100 dial (starts 50) that your conduct moves —
|
||||
/// clean deals and paying the shark raise it (fences pay a touch more, cops
|
||||
/// look away a little more); muggings, fights and dodging the shark lower it.
|
||||
/// - **Investments** (Bronx only): a second trench coat (+30 capacity), a Fence
|
||||
/// (auto-sells your stock each arrival above market, no travel), and a Shark
|
||||
/// Protection fee (lowers his interest, raises his warning threshold).
|
||||
/// - **Chase as a decision**: RUN / FIGHT / BRIBE — the outcome scales with
|
||||
/// what you commit (bribe cost grows with the number of pursuers) rather than
|
||||
/// a blind coin flip.
|
||||
/// - **The shark's favors**: keep the tab under a cap and he cuts your
|
||||
/// interest (good will). He also occasionally sends a risky no-profit job
|
||||
/// (a delivery run that raises your heat) — do it to keep his favor, refuse
|
||||
/// and he gets annoyed (terms revert, chase risk).
|
||||
///
|
||||
/// Classic-fidelity note: wave 1 intentionally diverges from the verbatim
|
||||
/// NerdWars price rolls (uniform per day) to add the momentum/mean-reversion
|
||||
/// model above; the seeded-RNG structure, event table, chase math, and scoring
|
||||
/// are otherwise preserved.
|
||||
struct DopeWarsSim {
|
||||
|
||||
// MARK: Items & locations
|
||||
@@ -101,6 +133,52 @@ struct DopeWarsSim {
|
||||
let isTrue: Bool
|
||||
}
|
||||
|
||||
/// A paid forecast from the Bronx contact: tomorrow's price for one product,
|
||||
/// 70% accurate. `truePrice` is the number it claims; `accurate` decides
|
||||
/// whether the market actually honors it.
|
||||
struct Forecast: Equatable {
|
||||
let slot: Int
|
||||
let location: Location
|
||||
let dayDue: Int
|
||||
let truePrice: Int
|
||||
let accurate: Bool
|
||||
}
|
||||
|
||||
/// A shark favor: a risky, no-profit job (a delivery run). Doing it raises
|
||||
/// your heat and earns goodwill; refusing loses it.
|
||||
struct Favor: Equatable {
|
||||
let slot: Int
|
||||
let destination: Location
|
||||
let unitCount: Int
|
||||
}
|
||||
|
||||
/// One-off investments (Bronx only), each bought once per run.
|
||||
enum Investment: Int, CaseIterable, Equatable {
|
||||
case coat = 0, fence, protection
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .coat: return "Second Trench Coat"
|
||||
case .fence: return "A Fence"
|
||||
case .protection: return "Shark Protection"
|
||||
}
|
||||
}
|
||||
var cost: Int {
|
||||
switch self {
|
||||
case .coat: return 2000
|
||||
case .fence: return 5000
|
||||
case .protection: return 3000
|
||||
}
|
||||
}
|
||||
var blurb: String {
|
||||
switch self {
|
||||
case .coat: return "+30 coat capacity (130 total)"
|
||||
case .fence: return "Auto-sells your stock above market, no travel"
|
||||
case .protection: return "Shark interest 10%→7%, warning cap doubled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent price multipliers per borough (rows follow `Location` order),
|
||||
/// applied to the daily band rolls. The Bronx is the neutral reference
|
||||
/// market; event prices are absolute news and ignore the bias.
|
||||
@@ -113,6 +191,39 @@ struct DopeWarsSim {
|
||||
[110, 105, 105, 105, 105, 105], // Brooklyn — fences pay over market
|
||||
]
|
||||
|
||||
/// Per-borough baseline price (rows follow `Location` order, cols `Item`)
|
||||
/// for the momentum model. ≈ the classic uniform band's midpoint × the
|
||||
/// borough bias, so a product's expected price is still "cheap in the
|
||||
/// Ghetto for heroin, dear in Manhattan" — but now it's a home to drift
|
||||
/// back toward instead of a fresh roll each day.
|
||||
static let boroughAnchor: [[Int]] = [
|
||||
[22500, 9500, 2750, 600, 170, 35], // Bronx
|
||||
[21375, 7125, 2475, 600, 145, 31], // Ghetto
|
||||
[23625, 10925, 2062, 480, 170, 33], // Central Park
|
||||
[29250, 10925, 3025, 690, 187, 38], // Manhattan
|
||||
[20250, 9025, 2612, 510, 153, 28], // Coney Island
|
||||
[24750, 9975, 2887, 630, 178, 31], // Brooklyn
|
||||
]
|
||||
/// Overlays for the two tier-upgraded slots (meth in the ludes slot,
|
||||
/// China White in the speed slot), per borough.
|
||||
static let methAnchor: [Int] = [11000, 10000, 11500, 14500, 9500, 11500]
|
||||
static let chinaWhiteAnchor: [Int] = [100000, 95000, 105000, 125000, 90000, 105000]
|
||||
|
||||
// MARK: Wave-1 tuning
|
||||
static let momentum: Double = 0.50 // share of yesterday's deviation carried into today
|
||||
static let noisePct: Double = 0.15 // daily random walk (±15%)
|
||||
static let shockDecay: Double = 0.70 // event shock retained per day
|
||||
static let historyLen = 7 // sparkline depth
|
||||
static let forecastCost = 300
|
||||
static let forecastAccuracy = 0.70
|
||||
static let repFenceBonusMax: Double = 0.10 // fence pays up to +10% at rep 100
|
||||
static let bribeBase = 800
|
||||
static let bribePerPursuer = 600
|
||||
static let sharkCapBase = 15_000
|
||||
static let sharkCapProtected = 30_000
|
||||
static let sharkInterestBase: Double = 0.10
|
||||
static let sharkInterestGood: Double = 0.07
|
||||
|
||||
// MARK: State (original BASIC variable in comments)
|
||||
|
||||
private(set) var wallet = 2000 // Z
|
||||
@@ -136,6 +247,17 @@ struct DopeWarsSim {
|
||||
private(set) var sharkChase = false // current chase is leg-breakers
|
||||
private var sharkWarned = false
|
||||
private var lastSharkDay = 0
|
||||
/// Wave-1 extension state.
|
||||
private var shock = [Double](repeating: 0, count: 6) // decaying event price offset
|
||||
private var history: [[Int]] = [[Int](repeating: 0, count: 6)] // last N days, newest last
|
||||
private(set) var forecast: Forecast?
|
||||
private(set) var favor: Favor?
|
||||
private(set) var rep = 50 // hidden reputation 0…100
|
||||
private(set) var hasFence = false
|
||||
private(set) var hasProtection = false
|
||||
private(set) var boughtInvestments: Set<Investment> = []
|
||||
private var lastRideDay: Int?
|
||||
private(set) var favorPendingDelivery: Favor?
|
||||
/// Days in the month; `Int.max` = endless (never ends on its own).
|
||||
let monthLen: Int
|
||||
/// Interstitial texts, shown one per tap like the original's Pause screens.
|
||||
@@ -148,6 +270,19 @@ struct DopeWarsSim {
|
||||
var totalHeld: Int { held.reduce(0, +) }
|
||||
var netWorth: Int { bank + wallet - debt }
|
||||
|
||||
/// Wave-1 derived values.
|
||||
/// The last `historyLen` days of prices per product, at the current borough.
|
||||
private(set) var spark: [[Int]] = []
|
||||
var sharkCap: Int { hasProtection ? Self.sharkCapProtected : Self.sharkCapBase }
|
||||
var inGoodStanding: Bool { debt <= sharkCap }
|
||||
/// Shark interest: 10% base, 7% while the tab is under the (raised) cap.
|
||||
var sharkRate: Double { inGoodStanding ? Self.sharkInterestGood : Self.sharkInterestBase }
|
||||
/// Fences pay a touch more the better your reputation (up to +10%).
|
||||
var fenceBonus: Double { Self.repFenceBonusMax * Double(rep) / 100 }
|
||||
/// A cop chase is less likely the higher your standing (cops look away).
|
||||
var heatMult: Double { 1.0 - 0.004 * Double(rep) } // rep 100 → ×0.6
|
||||
var bribeCost: Int { Self.bribeBase + Self.bribePerPursuer * max(1, chaseCops + 1) }
|
||||
|
||||
/// What a market slot is called at the current tier: the ludes slot deals
|
||||
/// meth from tier 2, the speed slot deals China White from tier 3.
|
||||
func displayName(_ item: Item) -> String {
|
||||
@@ -179,6 +314,13 @@ struct DopeWarsSim {
|
||||
rng = SeededGenerator(seed: s)
|
||||
extRng = SeededGenerator(seed: s ^ 0x5EED_0FF5)
|
||||
self.monthLen = monthLen <= 0 ? Int.max : monthLen
|
||||
// Seed each product near its borough anchor (small jitter) so the
|
||||
// momentum model has a sane starting price instead of a raw uniform.
|
||||
for i in 0..<6 {
|
||||
price[i] = max(1, Int(Double(anchor(for: i, at: .bronx))
|
||||
* Double(0.85 + 0.30 * (Double(rng.next() >> 11) * 0x1.0p-53))))
|
||||
}
|
||||
shock = [Double](repeating: 0, count: 6)
|
||||
rollDay() // G8 runs before the first menu
|
||||
}
|
||||
|
||||
@@ -200,28 +342,48 @@ struct DopeWarsSim {
|
||||
|
||||
// MARK: - Day roll (Lbl G8)
|
||||
|
||||
/// Fresh prices at the tier's bands, then the day's 0…20 event roll
|
||||
/// (most values do nothing). Roll shapes mirror the BASIC's ×100/×10
|
||||
/// quantisation for the cheaper drugs. One rand() per slot at every tier,
|
||||
/// so classic (tier 1) runs stay roll-for-roll identical to the original.
|
||||
/// Wave-1 day roll. Each product's price carries part of yesterday's move
|
||||
/// (momentum), drifts back toward this borough's anchor (mean-reversion),
|
||||
/// and takes a random walk — then the decaying event shock is applied.
|
||||
/// One rand() per slot keeps the seeded sequence stable.
|
||||
private mutating func rollDay() {
|
||||
checkMarketShift()
|
||||
price[Item.cocaine.rawValue] = Int((rand() * 15000 + 15000).rounded()) // 15000…30000
|
||||
price[Item.heroin.rawValue] = Int((rand() * 9000 + 5000).rounded()) // 5000…14000
|
||||
price[Item.acid.rawValue] = Int((rand() * 35 + 10).rounded()) * 100 // 1000…4500
|
||||
price[Item.weed.rawValue] = Int((rand() * 60 + 30).rounded()) * 10 // 300…900
|
||||
price[Item.speed.rawValue] = tier >= 3
|
||||
? Int((rand() * 1200 + 400).rounded()) * 100 // CW: 40000…160000
|
||||
: Int((rand() * 16 + 9).rounded()) * 10 // 90…250
|
||||
price[Item.ludes.rawValue] = tier >= 2
|
||||
? Int((rand() * 140 + 20).rounded()) * 100 // meth: 2000…16000
|
||||
: Int((rand() * 5 + 1).rounded()) * 10 // 10…60
|
||||
let bias = Self.boroughBias[location.rawValue]
|
||||
for i in 0..<6 { price[i] = max(1, price[i] * bias[i] / 100) }
|
||||
for i in 0..<6 {
|
||||
let a = Double(anchor(for: i, at: location))
|
||||
let prev = Double(price[i])
|
||||
let s = shock[i]
|
||||
let base = prev - s // price with the event shock stripped
|
||||
let next = a // mean-reversion home
|
||||
+ Self.momentum * (base - a) // carry part of yesterday's deviation
|
||||
+ Self.noisePct * base * (2.0 * rand() - 1.0) // random walk
|
||||
+ s // the decaying event shock
|
||||
price[i] = max(1, Int(next.rounded()))
|
||||
shock[i] = s * Self.shockDecay
|
||||
}
|
||||
// Record today's prices (at this borough) for the sparkline.
|
||||
history.append(price)
|
||||
if history.count > Self.historyLen { history.removeFirst(history.count - Self.historyLen) }
|
||||
rebuildSpark()
|
||||
resolveRumor()
|
||||
resolveForecast()
|
||||
applyEvent(Int((rand() * 20).rounded()))
|
||||
if phase == .trading { sharkPressure() }
|
||||
if phase == .trading { maybeSpawnRumor() }
|
||||
if phase == .trading { maybeSpawnFavor() }
|
||||
if phase == .trading { fenceArrival() }
|
||||
}
|
||||
|
||||
/// The borough anchor for a product slot at the current tier.
|
||||
private func anchor(for i: Int, at loc: Location) -> Int {
|
||||
let l = loc.rawValue
|
||||
if i == Item.ludes.rawValue, tier >= 2 { return Self.methAnchor[l] }
|
||||
if i == Item.speed.rawValue, tier >= 3 { return Self.chinaWhiteAnchor[l] }
|
||||
return Self.boroughAnchor[l][i]
|
||||
}
|
||||
|
||||
/// Recompute the per-product spark (last N days, oldest → newest).
|
||||
private mutating func rebuildSpark() {
|
||||
spark = (0..<6).map { i in history.map { $0[i] } }
|
||||
}
|
||||
|
||||
// MARK: - Word on the street (remake extension)
|
||||
@@ -236,12 +398,15 @@ struct DopeWarsSim {
|
||||
if !r.isTrue {
|
||||
messages.append("The word was garbage. \(name) is trading like any other day.")
|
||||
} else if r.isSpike {
|
||||
shock[r.slot] += 2.0 * Double(price[r.slot]) // ×3 for today, then decay
|
||||
price[r.slot] *= 3
|
||||
messages.append("The word was good — \(name) is going crazy here today!!")
|
||||
} else {
|
||||
shock[r.slot] -= 2.0 * Double(price[r.slot] - 1) // ÷3 for today, then decay
|
||||
price[r.slot] = max(1, price[r.slot] / 3)
|
||||
messages.append("The word was good — \(name) is practically free here today!")
|
||||
}
|
||||
rebuildSpark()
|
||||
}
|
||||
|
||||
private mutating func maybeSpawnRumor() {
|
||||
@@ -258,13 +423,162 @@ struct DopeWarsSim {
|
||||
: "Word on the street: a big load of \(name) hits \(loc.name) tomorrow — prices'll tank.")
|
||||
}
|
||||
|
||||
// MARK: - Wave 1: paid information
|
||||
|
||||
/// The Bronx contact sells tomorrow's price for one product, 70% accurate.
|
||||
@discardableResult
|
||||
mutating func buyForecast(for item: Item) -> Bool {
|
||||
guard phase == .trading, inBronx, wallet >= Self.forecastCost else { return false }
|
||||
wallet -= Self.forecastCost
|
||||
let acc = extRand() < Self.forecastAccuracy
|
||||
let loc = Location.allCases[Int(extRand() * 6)]
|
||||
let a = anchor(for: item.rawValue, at: loc)
|
||||
// The quoted price sits near the real target; an inaccurate quote is a
|
||||
// big miss so it's clearly wrong when you get there.
|
||||
let target = acc ? a : Int(Double(a) * (extRand() < 0.5 ? 0.45 : 1.9))
|
||||
forecast = Forecast(slot: item.rawValue, location: loc, dayDue: day + 1,
|
||||
truePrice: max(1, target), accurate: acc)
|
||||
let name = displayName(item).lowercased()
|
||||
messages.append("The contact leans in: \"\(name) in \(loc.name) tomorrow — you'll be looking at about \(money(target)).\"")
|
||||
return true
|
||||
}
|
||||
|
||||
private mutating func resolveForecast() {
|
||||
guard let f = forecast, day >= f.dayDue else { return }
|
||||
forecast = nil
|
||||
guard day == f.dayDue, location == f.location else { return }
|
||||
let name = displayName(Item(rawValue: f.slot)!).lowercased()
|
||||
if f.accurate {
|
||||
shock[f.slot] += Double(f.truePrice - price[f.slot]) // steer toward the quoted price
|
||||
price[f.slot] = f.truePrice
|
||||
messages.append("The contact's word held — \(name) is running right around \(money(f.truePrice)) here.")
|
||||
} else {
|
||||
messages.append("The contact's number was off — \(name) is trading like any other day.")
|
||||
}
|
||||
rebuildSpark()
|
||||
}
|
||||
|
||||
private func money(_ n: Int) -> String {
|
||||
"$" + String(format: "%.0f", Double(n))
|
||||
}
|
||||
|
||||
// MARK: - Wave 1: reputation-driven fence
|
||||
|
||||
/// If you own a Fence and arrived today (a ride or a successful escape),
|
||||
/// it quietly sells any stock above market — no travel needed. A good
|
||||
/// reputation gets you a touch more.
|
||||
private mutating func fenceArrival() {
|
||||
guard hasFence, let last = lastRideDay, last == day else { return }
|
||||
var sold = 0, cash = 0
|
||||
for i in 0..<6 where held[i] > 0 {
|
||||
let n = held[i]
|
||||
let each = Int((Double(price[i]) * (1 + fenceBonus)).rounded())
|
||||
cash += each * n
|
||||
held[i] = 0
|
||||
sold += n
|
||||
}
|
||||
guard sold > 0 else { return }
|
||||
wallet += cash
|
||||
recomputeFree()
|
||||
changeRep(1)
|
||||
messages.append("Your fence clears \(sold) units over market for \(money(cash)) while you were out.")
|
||||
}
|
||||
|
||||
// MARK: - Wave 1: investments (Bronx only)
|
||||
|
||||
mutating func canBuyInvestment(_ inv: Investment) -> Bool {
|
||||
inBronx && !boughtInvestments.contains(inv) && wallet >= inv.cost
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func buyInvestment(_ inv: Investment) -> Bool {
|
||||
guard phase == .trading, inBronx, !boughtInvestments.contains(inv) else { return false }
|
||||
guard wallet >= inv.cost else { return false }
|
||||
wallet -= inv.cost
|
||||
boughtInvestments.insert(inv)
|
||||
switch inv {
|
||||
case .coat:
|
||||
coatTotal += 30
|
||||
recomputeFree()
|
||||
messages.append("You pick up a second trench coat. 130 pockets now.")
|
||||
case .fence:
|
||||
hasFence = true
|
||||
messages.append("You cut a deal with a fence. He'll move your stock over market — no subway needed.")
|
||||
case .protection:
|
||||
hasProtection = true
|
||||
messages.append("You pay the shark's fee. He'll take his time with you now (interest down, warning cap doubled).")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Wave 1: the shark's favors
|
||||
|
||||
/// Occasionally (more often when you're carrying a tab) the shark sends a
|
||||
/// risky no-profit job. Doing it earns goodwill; refusing loses it.
|
||||
private mutating func maybeSpawnFavor() {
|
||||
guard favor == nil, day < monthLen, debt >= Self.sharkCapBase / 3 else { return }
|
||||
guard extRand() < (debt >= sharkCap ? 0.30 : 0.15) else { return }
|
||||
let slot = min(5, Int(extRand() * 6))
|
||||
let dest = Location.allCases.first { $0 != location } ?? .manhattan
|
||||
let units = 20 + Int(extRand() * 21) // 20…40
|
||||
favor = Favor(slot: slot, destination: dest, unitCount: units)
|
||||
let name = displayName(Item(rawValue: slot)!).lowercased()
|
||||
messages.append("The shark's kid: \"My man wants \(units) units of \(name) moved to \(dest.name) today. No pay — it's a favor. Do it, or we talk about your tab.\"")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func acceptFavor() -> Bool {
|
||||
guard let f = favor, phase == .trading, totalHeld + f.unitCount <= coatTotal else {
|
||||
messages.append("No room in your coat for that job.")
|
||||
return false
|
||||
}
|
||||
let cost = price[f.slot] * f.unitCount
|
||||
guard wallet >= cost else {
|
||||
messages.append("You can't front the cash to move that much.")
|
||||
return false
|
||||
}
|
||||
// Buy the product now (in the current borough), ride to the destination,
|
||||
// then sell it there for the same price — no profit, and it raises heat.
|
||||
wallet -= cost
|
||||
held[f.slot] += f.unitCount
|
||||
recomputeFree()
|
||||
favor = nil
|
||||
messages.append("You load up \(f.unitCount) units of \(displayName(Item(rawValue: f.slot)!)). Ride to \(f.destination.name) and dump it — no pay, just keeping the shark's favor.")
|
||||
favorPendingDelivery = f
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func refuseFavor() -> Bool {
|
||||
guard let f = favor else { return false }
|
||||
favor = nil
|
||||
changeRep(-6)
|
||||
lastSharkDay = day
|
||||
sharkWarned = true
|
||||
messages.append("You tell the kid to tell his man to collect the usual. The shark won't forget the slight.")
|
||||
return true
|
||||
}
|
||||
|
||||
/// If you ride to the favor's destination while it's pending, dump the
|
||||
/// stock there (no profit) and collect the goodwill.
|
||||
private mutating func resolveFavorDelivery() {
|
||||
guard let f = favorPendingDelivery, location == f.destination else { return }
|
||||
favorPendingDelivery = nil
|
||||
let n = min(f.unitCount, held[f.slot])
|
||||
held[f.slot] -= n
|
||||
recomputeFree()
|
||||
wallet += price[f.slot] * n // sold at local price ≈ no profit
|
||||
changeRep(4)
|
||||
messages.append("You dump the load. Word gets back to the shark — you're a man of your word.")
|
||||
}
|
||||
|
||||
// MARK: - The shark collects (remake extension)
|
||||
|
||||
/// Run a $15k+ tab past day 10 and the shark sends people: one warning,
|
||||
/// then a 30%-per-arrival chance his two leg-breakers jump you — the
|
||||
/// classic chase engine with nastier stakes.
|
||||
/// Run the tab past the shark's cap (doubled by protection) and he sends
|
||||
/// people: one warning, then a 30%-per-arrival chance his two leg-breakers
|
||||
/// jump you — the classic chase engine with nastier stakes.
|
||||
private mutating func sharkPressure() {
|
||||
guard debt >= 15_000, day >= 10, day != lastSharkDay else { return }
|
||||
guard debt >= sharkCap, day >= 10, day != lastSharkDay else { return }
|
||||
if !sharkWarned {
|
||||
sharkWarned = true
|
||||
lastSharkDay = day
|
||||
@@ -310,28 +624,30 @@ struct DopeWarsSim {
|
||||
if tier >= 2 {
|
||||
// The ludes slot deals meth now — its crash event becomes a spike.
|
||||
messages.append("A meth lab blew up in Jersey! Local product is suddenly precious!!")
|
||||
price[Item.ludes.rawValue] = Int(30000 + rand() * 18000) // 30000…47999
|
||||
shock[Item.ludes.rawValue] += 18_000 + rand() * 18_000
|
||||
} else {
|
||||
messages.append("Rival dealers raided a pharmacy and are selling cheap ludes!!")
|
||||
price[Item.ludes.rawValue] = 2
|
||||
shock[Item.ludes.rawValue] -= Double(price[Item.ludes.rawValue]) - 2
|
||||
}
|
||||
case 2:
|
||||
messages.append("The market has been flooded with cheap home-grown weed!")
|
||||
price[Item.weed.rawValue] = 122
|
||||
shock[Item.weed.rawValue] -= Double(price[Item.weed.rawValue]) - 122
|
||||
case 3:
|
||||
messages.append("The market has been flooded with cheap heroin!")
|
||||
price[Item.heroin.rawValue] = Int(850 + rand() * 1150) // 850…1999
|
||||
shock[Item.heroin.rawValue] -= Double(price[Item.heroin.rawValue]) - (850 + rand() * 1150)
|
||||
case 4, 5:
|
||||
messages.append("Cops made a big bust! Heroin prices are outrageous!")
|
||||
price[Item.heroin.rawValue] = Int(18000 + rand() * 25000) // 18000…42999
|
||||
shock[Item.heroin.rawValue] += 18_000 + rand() * 25_000
|
||||
case 6, 7:
|
||||
messages.append("Addicts are buying cocaine at outrageous prices!")
|
||||
price[Item.cocaine.rawValue] = Int(80000 + rand() * 60000) // 80000…139999
|
||||
shock[Item.cocaine.rawValue] += 80_000 + rand() * 60_000
|
||||
case 8:
|
||||
messages.append("You were mugged in the subway!")
|
||||
wallet = (wallet / 3) * 2 // int(Z/3) then ×2 — keep ~2/3
|
||||
changeRep(-4) // mugged — looks bad
|
||||
case 9, 10, 11:
|
||||
guard totalHeld >= 50 else { return } // big carriers draw the heat
|
||||
// Big carriers draw the heat; a good reputation softens it.
|
||||
guard totalHeld >= 50, extRand() < heatMult else { return }
|
||||
chaseCops = d == 9 ? 1 : (d == 10 ? 3 : 4)
|
||||
messages.append("Officer Hardass and \(chaseCops) of his deputies are chasing you!!")
|
||||
phase = .chase
|
||||
@@ -354,18 +670,28 @@ struct DopeWarsSim {
|
||||
messages.append("You found \(f) units of \(lootName(item)) on a dead dude in the subway!")
|
||||
case 17:
|
||||
messages.append("The market has been flooded with cheap home-made acid!")
|
||||
price[Item.acid.rawValue] = Int(250 + rand() * 550) // 250…799
|
||||
shock[Item.acid.rawValue] -= Double(price[Item.acid.rawValue]) - (250 + rand() * 550)
|
||||
case 18:
|
||||
guard tier >= 3 else { return } // idle roll in the classic game
|
||||
messages.append("The cartel flooded the docks! China White is going for nothing!!")
|
||||
price[Item.speed.rawValue] = Int(8000 + rand() * 12000) // 8000…19999
|
||||
shock[Item.speed.rawValue] -= Double(price[Item.speed.rawValue]) - (8000 + rand() * 12000)
|
||||
case 19:
|
||||
guard tier >= 2 else { return } // idle roll in the classic game
|
||||
messages.append("A superlab upstate dumped its stock — meth is dirt cheap!!")
|
||||
price[Item.ludes.rawValue] = Int(400 + rand() * 600) // 400…999
|
||||
shock[Item.ludes.rawValue] -= Double(price[Item.ludes.rawValue]) - (400 + rand() * 600)
|
||||
default:
|
||||
break // 0, 20 and gated rolls
|
||||
}
|
||||
// Recompute today's displayed price so the shock shows immediately.
|
||||
for i in 0..<6 where shock[i] != 0 {
|
||||
price[i] = max(1, Int((Double(price[i]) + shock[i]).rounded()))
|
||||
}
|
||||
rebuildSpark()
|
||||
}
|
||||
|
||||
/// Nudge the hidden reputation dial (clamped 0…100).
|
||||
private mutating func changeRep(_ delta: Int) {
|
||||
rep = min(100, max(0, rep + delta))
|
||||
}
|
||||
|
||||
private mutating func recomputeFree() { // (T−M−N−O−P−Q−R)→K
|
||||
@@ -388,9 +714,17 @@ struct DopeWarsSim {
|
||||
mutating func buy(_ item: Item, count: Int) -> Bool {
|
||||
guard phase == .trading, messages.isEmpty, count >= 0 else { return false }
|
||||
guard count <= coatFree, count <= affordable(item) else { return false }
|
||||
wallet -= price[item.rawValue] * count
|
||||
held[item.rawValue] += count
|
||||
recomputeFree()
|
||||
marketImpact(item, count: count, buying: true) // price moves first…
|
||||
let paid = price[item.rawValue] * count // …so you pay the moved price
|
||||
guard paid <= wallet else {
|
||||
held[item.rawValue] -= count // can't afford it after the move — undo
|
||||
recomputeFree()
|
||||
marketImpact(item, count: count, buying: false)
|
||||
return false
|
||||
}
|
||||
wallet -= paid
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -401,9 +735,28 @@ struct DopeWarsSim {
|
||||
held[item.rawValue] -= count
|
||||
wallet += price[item.rawValue] * count
|
||||
recomputeFree()
|
||||
marketImpact(item, count: count, buying: false)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Supply/demand: a big trade nudges today's local price against you.
|
||||
/// The effect scales with the share of the day's implied volume you moved,
|
||||
/// capped so a single trade can't blow the market out of the water.
|
||||
private mutating func marketImpact(_ item: Item, count: Int, buying: Bool) {
|
||||
guard count > 0 else { return }
|
||||
let i = item.rawValue
|
||||
let volume = max(100, coatTotal) // a "day's worth" of product
|
||||
let fraction = min(0.5, Double(count) / Double(volume))
|
||||
let delta = Double(price[i]) * fraction * (buying ? 1 : -1)
|
||||
price[i] = max(1, Int((Double(price[i]) + delta).rounded()))
|
||||
rebuildSpark()
|
||||
if fraction >= 0.25 {
|
||||
messages.append(buying
|
||||
? "Your heavy buying pushes \(displayName(item)) up here today."
|
||||
: "Your dump of \(displayName(item)) tanks the local price.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The subway (Lbl G5/S)
|
||||
|
||||
/// Rides to a borough: day advances, debt grows 10%, bank grows 6%
|
||||
@@ -419,9 +772,12 @@ struct DopeWarsSim {
|
||||
}
|
||||
location = destination
|
||||
day += 1
|
||||
// int(Y*1.1) / int(V*1.06), capped so endless runs can't overflow.
|
||||
debt = min(debt * 11 / 10, 999_999_999_999)
|
||||
bank = min(bank * 106 / 100, 999_999_999_999)
|
||||
lastRideDay = day
|
||||
// Debt grows at the shark's rate (10% base, 7% in good standing); the
|
||||
// bank still earns 6%. Capped so endless runs can't overflow.
|
||||
debt = min(Int((Double(debt) * (1 + sharkRate)).rounded()), 999_999_999_999)
|
||||
bank = min(Int((Double(bank) * 1.06).rounded()), 999_999_999_999)
|
||||
resolveFavorDelivery()
|
||||
if day > monthLen { endGame(.monthOver) } else { rollDay() }
|
||||
return true
|
||||
}
|
||||
@@ -434,6 +790,7 @@ struct DopeWarsSim {
|
||||
guard amount >= 0, amount <= wallet, amount <= debt else { return false }
|
||||
debt -= amount
|
||||
wallet -= amount
|
||||
if amount >= 1000 { changeRep(2) } // squaring up earns respect
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -501,6 +858,7 @@ struct DopeWarsSim {
|
||||
messages.append("You lost them in an alley!")
|
||||
sharkChase = false
|
||||
phase = .trading
|
||||
changeRep(-2) // running from the law looks guilty
|
||||
rollDay()
|
||||
} else {
|
||||
messages.append("You can't shake them!")
|
||||
@@ -508,6 +866,26 @@ struct DopeWarsSim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wave-1: pay them off. Costs more the more pursuers there are; you can't
|
||||
/// afford it and you're out of options. Ends the chase clean.
|
||||
mutating func chaseBribe() {
|
||||
guard phase == .chase, messages.isEmpty else { return }
|
||||
let cost = bribeCost
|
||||
guard wallet >= cost else {
|
||||
messages.append("You can't afford to sweeten the pot!")
|
||||
return
|
||||
}
|
||||
wallet -= cost
|
||||
let wasShark = sharkChase
|
||||
messages.append(wasShark
|
||||
? "You slide \(cost) of cash across. The goons step back — the shark will remember."
|
||||
: "You pass over \(cost). Officer Hardass turns up his collar and walks off.")
|
||||
sharkChase = false
|
||||
changeRep(wasShark ? 0 : 1)
|
||||
phase = .trading
|
||||
if !wasShark { rollDay() }
|
||||
}
|
||||
|
||||
/// Needs a gun; 50/50 miss or drop one deputy. Either way (unless the last
|
||||
/// one falls) they answer with a fire round, exactly like the original's
|
||||
/// fall-through to Lbl O.
|
||||
@@ -522,6 +900,7 @@ struct DopeWarsSim {
|
||||
} else {
|
||||
chaseCops -= 1
|
||||
messages.append("You dropped one!!!")
|
||||
changeRep(-4) // pulling a gun on the law
|
||||
if chaseCops == -1 { // If D==(−1) Goto N
|
||||
chaseVictory()
|
||||
return
|
||||
@@ -553,6 +932,7 @@ struct DopeWarsSim {
|
||||
sharkChase = false
|
||||
let loot = Int(rand() * 500 + 250) // 250…749 collection money
|
||||
wallet += loot
|
||||
changeRep(-6) // you shot the shark's boys
|
||||
messages.append("You took them both down!!!")
|
||||
messages.append("You found $\(loot) of the shark's collection money. He won't forget this.")
|
||||
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||
@@ -560,6 +940,7 @@ struct DopeWarsSim {
|
||||
}
|
||||
let loot = Int(rand() * 1250 + 750) // 750…1999
|
||||
wallet += loot
|
||||
changeRep(-5) // you took down the law
|
||||
messages.append("You took them all down!!!")
|
||||
messages.append("You found $\(loot) on Officer Hardass's body.")
|
||||
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||
|
||||
Reference in New Issue
Block a user