From bcc2ad490c6c9828db4a7cf1e2df99f90369bd82 Mon Sep 17 00:00:00 2001 From: Kyle Albanese Date: Thu, 17 Sep 2026 11:34:01 -0400 Subject: [PATCH] Dope Wars wave 1: momentum market, sparklines, market impact, info, rep, investments, chase bribe, shark favors --- Arcade85/Games/DopeWars/DopeWarsScene.swift | 218 +++++++++- Arcade85/Games/DopeWars/DopeWarsSim.swift | 449 ++++++++++++++++++-- 2 files changed, 617 insertions(+), 50 deletions(-) diff --git a/Arcade85/Games/DopeWars/DopeWarsScene.swift b/Arcade85/Games/DopeWars/DopeWarsScene.swift index 1ae7723..8a52b8e 100644 --- a/Arcade85/Games/DopeWars/DopeWarsScene.swift +++ b/Arcade85/Games/DopeWars/DopeWarsScene.swift @@ -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) { diff --git a/Arcade85/Games/DopeWars/DopeWarsSim.swift b/Arcade85/Games/DopeWars/DopeWarsSim.swift index 4d01042..45a3aa9 100644 --- a/Arcade85/Games/DopeWars/DopeWarsSim.swift +++ b/Arcade85/Games/DopeWars/DopeWarsSim.swift @@ -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 = [] + 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