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 pickTrade(DopeWarsSim.Item) // BUY or SELL this row?
|
||||||
case amount(AmountMode)
|
case amount(AmountMode)
|
||||||
case keypad(AmountMode) // type the amount directly
|
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
|
private var mode: Mode = .ready
|
||||||
@@ -66,6 +66,7 @@ final class DopeWarsScene: SKScene {
|
|||||||
private var rowHeld: [SKLabelNode] = []
|
private var rowHeld: [SKLabelNode] = []
|
||||||
private var rowArrows: [SKLabelNode] = []
|
private var rowArrows: [SKLabelNode] = []
|
||||||
private var rowBacks: [SKSpriteNode] = []
|
private var rowBacks: [SKSpriteNode] = []
|
||||||
|
private var rowSparks: [SKShapeNode] = []
|
||||||
private var lastWalletShown = Int.min
|
private var lastWalletShown = Int.min
|
||||||
|
|
||||||
private var pillNodes: [String: SKShapeNode] = [:]
|
private var pillNodes: [String: SKShapeNode] = [:]
|
||||||
@@ -345,7 +346,7 @@ final class DopeWarsScene: SKScene {
|
|||||||
boardGroup.zPosition = 5
|
boardGroup.zPosition = 5
|
||||||
addChild(boardGroup)
|
addChild(boardGroup)
|
||||||
rowNames.removeAll(); rowPrices.removeAll(); rowHeld.removeAll()
|
rowNames.removeAll(); rowPrices.removeAll(); rowHeld.removeAll()
|
||||||
rowArrows.removeAll(); rowBacks.removeAll()
|
rowArrows.removeAll(); rowBacks.removeAll(); rowSparks.removeAll()
|
||||||
|
|
||||||
let hudBottom = size.height - topInset - 82
|
let hudBottom = size.height - topInset - 82
|
||||||
let barTop = bottomInset + 88
|
let barTop = bottomInset + 88
|
||||||
@@ -431,6 +432,19 @@ final class DopeWarsScene: SKScene {
|
|||||||
arrow.zPosition = 2
|
arrow.zPosition = 2
|
||||||
boardGroup.addChild(arrow)
|
boardGroup.addChild(arrow)
|
||||||
rowArrows.append(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()
|
statusLine.removeFromParent()
|
||||||
@@ -458,7 +472,7 @@ final class DopeWarsScene: SKScene {
|
|||||||
("act.subway", "SUBWAY", Palette.mint),
|
("act.subway", "SUBWAY", Palette.mint),
|
||||||
("act.shark", "SHARK", Palette.amber),
|
("act.shark", "SHARK", Palette.amber),
|
||||||
("act.bank", "BANK", Palette.cyan),
|
("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 gap: CGFloat = 10
|
||||||
let sideMargin: CGFloat = 16
|
let sideMargin: CGFloat = 16
|
||||||
@@ -540,13 +554,17 @@ final class DopeWarsScene: SKScene {
|
|||||||
} else {
|
} else {
|
||||||
rowArrows[i].text = ""
|
rowArrows[i].text = ""
|
||||||
}
|
}
|
||||||
|
drawSpark(rowSparks[i], values: sim.spark[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
var status: [String] = []
|
var status: [String] = []
|
||||||
if sim.guns > 0 { status.append("GUNS \(sim.guns)") }
|
if sim.guns > 0 { status.append("GUNS \(sim.guns)") }
|
||||||
if sim.wounds > 0 { status.append("WOUNDS \(sim.wounds)/50") }
|
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
|
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.text = status.joined(separator: " · ")
|
||||||
statusLine.fontColor = sim.wounds >= 30 ? Palette.red : Palette.white70
|
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
|
// MARK: - Mode machine
|
||||||
|
|
||||||
private func setMode(_ new: Mode) {
|
private func setMode(_ new: Mode) {
|
||||||
@@ -636,6 +676,10 @@ final class DopeWarsScene: SKScene {
|
|||||||
case .ride: buildRideSheet()
|
case .ride: buildRideSheet()
|
||||||
case .shark: buildSharkSheet()
|
case .shark: buildSharkSheet()
|
||||||
case .bank: buildBankSheet()
|
case .bank: buildBankSheet()
|
||||||
|
case .contact: buildContactSheet()
|
||||||
|
case .investments: buildInvestmentsSheet()
|
||||||
|
case .favor: buildFavorSheet()
|
||||||
|
case .bronxMenu: buildBronxMenuSheet()
|
||||||
case .confirmEnd: buildConfirmEndSheet()
|
case .confirmEnd: buildConfirmEndSheet()
|
||||||
case .gameOver: buildGameOverSheet()
|
case .gameOver: buildGameOverSheet()
|
||||||
}
|
}
|
||||||
@@ -737,11 +781,11 @@ final class DopeWarsScene: SKScene {
|
|||||||
let rules = [
|
let rules = [
|
||||||
chosenLen == 0 ? "No clock — deal until they get you."
|
chosenLen == 0 ? "No clock — deal until they get you."
|
||||||
: "\(chosenLen) days, then the month ends and you're scored.",
|
: "\(chosenLen) days, then the month ends and you're scored.",
|
||||||
"You owe the shark $5,500. Debt grows 10% a day.",
|
"Prices trend — read the 7-day sparkline, buy the dip.",
|
||||||
"A ride burns a day. Every borough prices its own way.",
|
"Big trades move the local price. Don't get greedy.",
|
||||||
"Your trench coat holds 100 units.",
|
"You owe the shark $5,500. Stay under his cap for good terms.",
|
||||||
"50+ units in the coat draws Officer Hardass.",
|
"The Bronx: shark, bank, a paid contact, and investments.",
|
||||||
"The Bronx is home: loan shark + 6%-a-day bank.",
|
"50+ units in the coat draws Officer Hardass — bribe, fight, or run.",
|
||||||
chosenLen == 0 ? "Endless runs are for fun — no score is posted."
|
chosenLen == 0 ? "Endless runs are for fun — no score is posted."
|
||||||
: "Score = final net worth (cash + bank − debt).",
|
: "Score = final net worth (cash + bank − debt).",
|
||||||
]
|
]
|
||||||
@@ -834,11 +878,16 @@ final class DopeWarsScene: SKScene {
|
|||||||
x: rect.maxX - 30, y: y, align: .right)
|
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,
|
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,
|
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) {
|
private func buildPickTradeSheet(_ item: DopeWarsSim.Item) {
|
||||||
@@ -1038,8 +1087,10 @@ final class DopeWarsScene: SKScene {
|
|||||||
let rect = sheetCard(height: 268)
|
let rect = sheetCard(height: 268)
|
||||||
sheetLabel("LOAN SHARK", font: "AvenirNext-Heavy", size: 20, color: .white,
|
sheetLabel("LOAN SHARK", font: "AvenirNext-Heavy", size: 20, color: .white,
|
||||||
x: rect.midX, y: rect.maxY - 32)
|
x: rect.midX, y: rect.maxY - 32)
|
||||||
sheetLabel("Debt \(money(sim.debt)) · Cash \(money(sim.wallet))",
|
let standing = sim.inGoodStanding ? " · GOOD TERMS (\(sim.sharkRate == 0.07 ? "7%" : "10%"))" : ""
|
||||||
font: "Menlo-Bold", size: 13, color: Palette.red, x: rect.midX, y: rect.maxY - 62)
|
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
|
let bw = rect.width - 44
|
||||||
sheetButton("shark.repay", "REPAY DEBT", color: Palette.mint,
|
sheetButton("shark.repay", "REPAY DEBT", color: Palette.mint,
|
||||||
rect: CGRect(x: rect.minX + 22, y: rect.midY - 8, width: bw, height: 48))
|
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)
|
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() {
|
private func buildConfirmEndSheet() {
|
||||||
dimLayer(0.55)
|
dimLayer(0.55)
|
||||||
let rect = sheetCard(height: 210)
|
let rect = sheetCard(height: 210)
|
||||||
@@ -1169,7 +1306,12 @@ final class DopeWarsScene: SKScene {
|
|||||||
if let key = hitKey(at: p) {
|
if let key = hitKey(at: p) {
|
||||||
Haptics.impact(.medium, intensity: 0.8)
|
Haptics.impact(.medium, intensity: 0.8)
|
||||||
let before = sim.price
|
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
|
if sim.price != before { notePriceMovement(from: before) } // escape reroll
|
||||||
syncFromSim()
|
syncFromSim()
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1361,44 @@ final class DopeWarsScene: SKScene {
|
|||||||
default: break
|
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:
|
case .confirmEnd:
|
||||||
switch hitKey(at: p) {
|
switch hitKey(at: p) {
|
||||||
case "end.yes":
|
case "end.yes":
|
||||||
@@ -1239,6 +1419,8 @@ final class DopeWarsScene: SKScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleBoardTap(_ p: CGPoint) {
|
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) {
|
for i in 0..<6 where rowRect(i).contains(p) {
|
||||||
guard let item = DopeWarsSim.Item(rawValue: i) else { continue }
|
guard let item = DopeWarsSim.Item(rawValue: i) else { continue }
|
||||||
Haptics.selection()
|
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() }
|
if sim.inBronx { setMode(.bank) } else { cardQueue.append("Your bank branch is in the Bronx…"); advanceCards() }
|
||||||
return
|
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) {
|
private func handleAmountTap(_ p: CGPoint, _ am: AmountMode) {
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ import Foundation
|
|||||||
/// ending if they put you down.
|
/// ending if they put you down.
|
||||||
/// - **Month lengths**: 30/60/90 days, or endless (no clock, no leaderboard;
|
/// - **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).
|
/// 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 {
|
struct DopeWarsSim {
|
||||||
|
|
||||||
// MARK: Items & locations
|
// MARK: Items & locations
|
||||||
@@ -101,6 +133,52 @@ struct DopeWarsSim {
|
|||||||
let isTrue: Bool
|
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),
|
/// Percent price multipliers per borough (rows follow `Location` order),
|
||||||
/// applied to the daily band rolls. The Bronx is the neutral reference
|
/// applied to the daily band rolls. The Bronx is the neutral reference
|
||||||
/// market; event prices are absolute news and ignore the bias.
|
/// 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
|
[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)
|
// MARK: State (original BASIC variable in comments)
|
||||||
|
|
||||||
private(set) var wallet = 2000 // Z
|
private(set) var wallet = 2000 // Z
|
||||||
@@ -136,6 +247,17 @@ struct DopeWarsSim {
|
|||||||
private(set) var sharkChase = false // current chase is leg-breakers
|
private(set) var sharkChase = false // current chase is leg-breakers
|
||||||
private var sharkWarned = false
|
private var sharkWarned = false
|
||||||
private var lastSharkDay = 0
|
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).
|
/// Days in the month; `Int.max` = endless (never ends on its own).
|
||||||
let monthLen: Int
|
let monthLen: Int
|
||||||
/// Interstitial texts, shown one per tap like the original's Pause screens.
|
/// 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 totalHeld: Int { held.reduce(0, +) }
|
||||||
var netWorth: Int { bank + wallet - debt }
|
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
|
/// 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.
|
/// meth from tier 2, the speed slot deals China White from tier 3.
|
||||||
func displayName(_ item: Item) -> String {
|
func displayName(_ item: Item) -> String {
|
||||||
@@ -179,6 +314,13 @@ struct DopeWarsSim {
|
|||||||
rng = SeededGenerator(seed: s)
|
rng = SeededGenerator(seed: s)
|
||||||
extRng = SeededGenerator(seed: s ^ 0x5EED_0FF5)
|
extRng = SeededGenerator(seed: s ^ 0x5EED_0FF5)
|
||||||
self.monthLen = monthLen <= 0 ? Int.max : monthLen
|
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
|
rollDay() // G8 runs before the first menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,28 +342,48 @@ struct DopeWarsSim {
|
|||||||
|
|
||||||
// MARK: - Day roll (Lbl G8)
|
// MARK: - Day roll (Lbl G8)
|
||||||
|
|
||||||
/// Fresh prices at the tier's bands, then the day's 0…20 event roll
|
/// Wave-1 day roll. Each product's price carries part of yesterday's move
|
||||||
/// (most values do nothing). Roll shapes mirror the BASIC's ×100/×10
|
/// (momentum), drifts back toward this borough's anchor (mean-reversion),
|
||||||
/// quantisation for the cheaper drugs. One rand() per slot at every tier,
|
/// and takes a random walk — then the decaying event shock is applied.
|
||||||
/// so classic (tier 1) runs stay roll-for-roll identical to the original.
|
/// One rand() per slot keeps the seeded sequence stable.
|
||||||
private mutating func rollDay() {
|
private mutating func rollDay() {
|
||||||
checkMarketShift()
|
checkMarketShift()
|
||||||
price[Item.cocaine.rawValue] = Int((rand() * 15000 + 15000).rounded()) // 15000…30000
|
for i in 0..<6 {
|
||||||
price[Item.heroin.rawValue] = Int((rand() * 9000 + 5000).rounded()) // 5000…14000
|
let a = Double(anchor(for: i, at: location))
|
||||||
price[Item.acid.rawValue] = Int((rand() * 35 + 10).rounded()) * 100 // 1000…4500
|
let prev = Double(price[i])
|
||||||
price[Item.weed.rawValue] = Int((rand() * 60 + 30).rounded()) * 10 // 300…900
|
let s = shock[i]
|
||||||
price[Item.speed.rawValue] = tier >= 3
|
let base = prev - s // price with the event shock stripped
|
||||||
? Int((rand() * 1200 + 400).rounded()) * 100 // CW: 40000…160000
|
let next = a // mean-reversion home
|
||||||
: Int((rand() * 16 + 9).rounded()) * 10 // 90…250
|
+ Self.momentum * (base - a) // carry part of yesterday's deviation
|
||||||
price[Item.ludes.rawValue] = tier >= 2
|
+ Self.noisePct * base * (2.0 * rand() - 1.0) // random walk
|
||||||
? Int((rand() * 140 + 20).rounded()) * 100 // meth: 2000…16000
|
+ s // the decaying event shock
|
||||||
: Int((rand() * 5 + 1).rounded()) * 10 // 10…60
|
price[i] = max(1, Int(next.rounded()))
|
||||||
let bias = Self.boroughBias[location.rawValue]
|
shock[i] = s * Self.shockDecay
|
||||||
for i in 0..<6 { price[i] = max(1, price[i] * bias[i] / 100) }
|
}
|
||||||
|
// 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()
|
resolveRumor()
|
||||||
|
resolveForecast()
|
||||||
applyEvent(Int((rand() * 20).rounded()))
|
applyEvent(Int((rand() * 20).rounded()))
|
||||||
if phase == .trading { sharkPressure() }
|
if phase == .trading { sharkPressure() }
|
||||||
if phase == .trading { maybeSpawnRumor() }
|
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)
|
// MARK: - Word on the street (remake extension)
|
||||||
@@ -236,12 +398,15 @@ struct DopeWarsSim {
|
|||||||
if !r.isTrue {
|
if !r.isTrue {
|
||||||
messages.append("The word was garbage. \(name) is trading like any other day.")
|
messages.append("The word was garbage. \(name) is trading like any other day.")
|
||||||
} else if r.isSpike {
|
} else if r.isSpike {
|
||||||
|
shock[r.slot] += 2.0 * Double(price[r.slot]) // ×3 for today, then decay
|
||||||
price[r.slot] *= 3
|
price[r.slot] *= 3
|
||||||
messages.append("The word was good — \(name) is going crazy here today!!")
|
messages.append("The word was good — \(name) is going crazy here today!!")
|
||||||
} else {
|
} 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)
|
price[r.slot] = max(1, price[r.slot] / 3)
|
||||||
messages.append("The word was good — \(name) is practically free here today!")
|
messages.append("The word was good — \(name) is practically free here today!")
|
||||||
}
|
}
|
||||||
|
rebuildSpark()
|
||||||
}
|
}
|
||||||
|
|
||||||
private mutating func maybeSpawnRumor() {
|
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.")
|
: "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)
|
// MARK: - The shark collects (remake extension)
|
||||||
|
|
||||||
/// Run a $15k+ tab past day 10 and the shark sends people: one warning,
|
/// Run the tab past the shark's cap (doubled by protection) and he sends
|
||||||
/// then a 30%-per-arrival chance his two leg-breakers jump you — the
|
/// people: one warning, then a 30%-per-arrival chance his two leg-breakers
|
||||||
/// classic chase engine with nastier stakes.
|
/// jump you — the classic chase engine with nastier stakes.
|
||||||
private mutating func sharkPressure() {
|
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 {
|
if !sharkWarned {
|
||||||
sharkWarned = true
|
sharkWarned = true
|
||||||
lastSharkDay = day
|
lastSharkDay = day
|
||||||
@@ -310,28 +624,30 @@ struct DopeWarsSim {
|
|||||||
if tier >= 2 {
|
if tier >= 2 {
|
||||||
// The ludes slot deals meth now — its crash event becomes a spike.
|
// 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!!")
|
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 {
|
} else {
|
||||||
messages.append("Rival dealers raided a pharmacy and are selling cheap ludes!!")
|
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:
|
case 2:
|
||||||
messages.append("The market has been flooded with cheap home-grown weed!")
|
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:
|
case 3:
|
||||||
messages.append("The market has been flooded with cheap heroin!")
|
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:
|
case 4, 5:
|
||||||
messages.append("Cops made a big bust! Heroin prices are outrageous!")
|
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:
|
case 6, 7:
|
||||||
messages.append("Addicts are buying cocaine at outrageous prices!")
|
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:
|
case 8:
|
||||||
messages.append("You were mugged in the subway!")
|
messages.append("You were mugged in the subway!")
|
||||||
wallet = (wallet / 3) * 2 // int(Z/3) then ×2 — keep ~2/3
|
wallet = (wallet / 3) * 2 // int(Z/3) then ×2 — keep ~2/3
|
||||||
|
changeRep(-4) // mugged — looks bad
|
||||||
case 9, 10, 11:
|
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)
|
chaseCops = d == 9 ? 1 : (d == 10 ? 3 : 4)
|
||||||
messages.append("Officer Hardass and \(chaseCops) of his deputies are chasing you!!")
|
messages.append("Officer Hardass and \(chaseCops) of his deputies are chasing you!!")
|
||||||
phase = .chase
|
phase = .chase
|
||||||
@@ -354,18 +670,28 @@ struct DopeWarsSim {
|
|||||||
messages.append("You found \(f) units of \(lootName(item)) on a dead dude in the subway!")
|
messages.append("You found \(f) units of \(lootName(item)) on a dead dude in the subway!")
|
||||||
case 17:
|
case 17:
|
||||||
messages.append("The market has been flooded with cheap home-made acid!")
|
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:
|
case 18:
|
||||||
guard tier >= 3 else { return } // idle roll in the classic game
|
guard tier >= 3 else { return } // idle roll in the classic game
|
||||||
messages.append("The cartel flooded the docks! China White is going for nothing!!")
|
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:
|
case 19:
|
||||||
guard tier >= 2 else { return } // idle roll in the classic game
|
guard tier >= 2 else { return } // idle roll in the classic game
|
||||||
messages.append("A superlab upstate dumped its stock — meth is dirt cheap!!")
|
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:
|
default:
|
||||||
break // 0, 20 and gated rolls
|
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
|
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 {
|
mutating func buy(_ item: Item, count: Int) -> Bool {
|
||||||
guard phase == .trading, messages.isEmpty, count >= 0 else { return false }
|
guard phase == .trading, messages.isEmpty, count >= 0 else { return false }
|
||||||
guard count <= coatFree, count <= affordable(item) else { return false }
|
guard count <= coatFree, count <= affordable(item) else { return false }
|
||||||
wallet -= price[item.rawValue] * count
|
|
||||||
held[item.rawValue] += count
|
held[item.rawValue] += count
|
||||||
recomputeFree()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,9 +735,28 @@ struct DopeWarsSim {
|
|||||||
held[item.rawValue] -= count
|
held[item.rawValue] -= count
|
||||||
wallet += price[item.rawValue] * count
|
wallet += price[item.rawValue] * count
|
||||||
recomputeFree()
|
recomputeFree()
|
||||||
|
marketImpact(item, count: count, buying: false)
|
||||||
return true
|
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)
|
// MARK: - The subway (Lbl G5/S)
|
||||||
|
|
||||||
/// Rides to a borough: day advances, debt grows 10%, bank grows 6%
|
/// Rides to a borough: day advances, debt grows 10%, bank grows 6%
|
||||||
@@ -419,9 +772,12 @@ struct DopeWarsSim {
|
|||||||
}
|
}
|
||||||
location = destination
|
location = destination
|
||||||
day += 1
|
day += 1
|
||||||
// int(Y*1.1) / int(V*1.06), capped so endless runs can't overflow.
|
lastRideDay = day
|
||||||
debt = min(debt * 11 / 10, 999_999_999_999)
|
// Debt grows at the shark's rate (10% base, 7% in good standing); the
|
||||||
bank = min(bank * 106 / 100, 999_999_999_999)
|
// 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() }
|
if day > monthLen { endGame(.monthOver) } else { rollDay() }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -434,6 +790,7 @@ struct DopeWarsSim {
|
|||||||
guard amount >= 0, amount <= wallet, amount <= debt else { return false }
|
guard amount >= 0, amount <= wallet, amount <= debt else { return false }
|
||||||
debt -= amount
|
debt -= amount
|
||||||
wallet -= amount
|
wallet -= amount
|
||||||
|
if amount >= 1000 { changeRep(2) } // squaring up earns respect
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +858,7 @@ struct DopeWarsSim {
|
|||||||
messages.append("You lost them in an alley!")
|
messages.append("You lost them in an alley!")
|
||||||
sharkChase = false
|
sharkChase = false
|
||||||
phase = .trading
|
phase = .trading
|
||||||
|
changeRep(-2) // running from the law looks guilty
|
||||||
rollDay()
|
rollDay()
|
||||||
} else {
|
} else {
|
||||||
messages.append("You can't shake them!")
|
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
|
/// 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
|
/// one falls) they answer with a fire round, exactly like the original's
|
||||||
/// fall-through to Lbl O.
|
/// fall-through to Lbl O.
|
||||||
@@ -522,6 +900,7 @@ struct DopeWarsSim {
|
|||||||
} else {
|
} else {
|
||||||
chaseCops -= 1
|
chaseCops -= 1
|
||||||
messages.append("You dropped one!!!")
|
messages.append("You dropped one!!!")
|
||||||
|
changeRep(-4) // pulling a gun on the law
|
||||||
if chaseCops == -1 { // If D==(−1) Goto N
|
if chaseCops == -1 { // If D==(−1) Goto N
|
||||||
chaseVictory()
|
chaseVictory()
|
||||||
return
|
return
|
||||||
@@ -553,6 +932,7 @@ struct DopeWarsSim {
|
|||||||
sharkChase = false
|
sharkChase = false
|
||||||
let loot = Int(rand() * 500 + 250) // 250…749 collection money
|
let loot = Int(rand() * 500 + 250) // 250…749 collection money
|
||||||
wallet += loot
|
wallet += loot
|
||||||
|
changeRep(-6) // you shot the shark's boys
|
||||||
messages.append("You took them both down!!!")
|
messages.append("You took them both down!!!")
|
||||||
messages.append("You found $\(loot) of the shark's collection money. He won't forget this.")
|
messages.append("You found $\(loot) of the shark's collection money. He won't forget this.")
|
||||||
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||||
@@ -560,6 +940,7 @@ struct DopeWarsSim {
|
|||||||
}
|
}
|
||||||
let loot = Int(rand() * 1250 + 750) // 750…1999
|
let loot = Int(rand() * 1250 + 750) // 750…1999
|
||||||
wallet += loot
|
wallet += loot
|
||||||
|
changeRep(-5) // you took down the law
|
||||||
messages.append("You took them all down!!!")
|
messages.append("You took them all down!!!")
|
||||||
messages.append("You found $\(loot) on Officer Hardass's body.")
|
messages.append("You found $\(loot) on Officer Hardass's body.")
|
||||||
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||||
|
|||||||
Reference in New Issue
Block a user