diff --git a/IdleTycoon/.gitignore b/IdleTycoon/.gitignore new file mode 100644 index 0000000..e729cdf --- /dev/null +++ b/IdleTycoon/.gitignore @@ -0,0 +1,6 @@ +xcuserdata/ +*.xcuserstate +DerivedData/ +.build/ +*.swp +.DS_Store diff --git a/IdleTycoon/IdleTycoon.xcodeproj/project.pbxproj b/IdleTycoon/IdleTycoon.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4896781 --- /dev/null +++ b/IdleTycoon/IdleTycoon.xcodeproj/project.pbxproj @@ -0,0 +1,415 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + D2ED4481D07F41BBA8234774 /* IdleTycoonApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FE1271ED1E2428A97F68C7C /* IdleTycoonApp.swift */; }; + BC9B84456FAC43F4857D9C05 /* Business.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9F726591794C569A591816 /* Business.swift */; }; + 21DBE6C9A9614671A01415E5 /* GameSave.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5900AA1CD4024F2CA90A66A7 /* GameSave.swift */; }; + 7C7D15BF4135464BA5D3686A /* Economy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05057D30AE84D6DBC7653A8 /* Economy.swift */; }; + B8875E0DB115463EA676893E /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8878A838C28A41769DAD9082 /* GameStore.swift */; }; + D1FE93A5A5A6458B87AA9202 /* GameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1485C58B1379417EA7C459BA /* GameViewModel.swift */; }; + 6718DF01B6EB4575A81ED92F /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA512C6F8EF4C1DAF036FE4 /* Theme.swift */; }; + 6B6E691494334F01BD58F825 /* BusinessRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF5FEDC42394BAE9857FCB8 /* BusinessRowView.swift */; }; + 1BD0D5FD76B34CE284D1DA40 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2576400E3843452290A04852 /* ContentView.swift */; }; + 25FACB143E3D441AB18AC90C /* EconomyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A81DB5BDA5491E8549CAC5 /* EconomyTests.swift */; }; + 5D7D0FE0D82E4FC6AC21748F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E80831150A2140D29AF378A1 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AC5F26DAAAAB479F9DD920C5 /* IdleTycoon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IdleTycoon.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D452D2BD9FC94649A99949A4 /* IdleTycoonTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IdleTycoonTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5FE1271ED1E2428A97F68C7C /* IdleTycoonApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleTycoonApp.swift; sourceTree = ""; }; + FA9F726591794C569A591816 /* Business.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Business.swift; sourceTree = ""; }; + 5900AA1CD4024F2CA90A66A7 /* GameSave.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSave.swift; sourceTree = ""; }; + E05057D30AE84D6DBC7653A8 /* Economy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Economy.swift; sourceTree = ""; }; + 8878A838C28A41769DAD9082 /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = ""; }; + 1485C58B1379417EA7C459BA /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = ""; }; + 7BA512C6F8EF4C1DAF036FE4 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + CEF5FEDC42394BAE9857FCB8 /* BusinessRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessRowView.swift; sourceTree = ""; }; + 2576400E3843452290A04852 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 53A81DB5BDA5491E8549CAC5 /* EconomyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EconomyTests.swift; sourceTree = ""; }; + E80831150A2140D29AF378A1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 503DD169140340089981C303 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FAEA05221DD47809E28CB21 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 951F407D31CD4F32B9C67A55 = { + isa = PBXGroup; + children = ( + E755F593EB0A40FB846D12FE /* IdleTycoon */, + 913CA0379F5E41CBA170EF9E /* IdleTycoonTests */, + 0F98AB2CBB6C4C0EAB6197B9 /* Products */, + ); + sourceTree = ""; + }; + 0F98AB2CBB6C4C0EAB6197B9 /* Products */ = { + isa = PBXGroup; + children = ( + AC5F26DAAAAB479F9DD920C5 /* IdleTycoon.app */, + D452D2BD9FC94649A99949A4 /* IdleTycoonTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + E755F593EB0A40FB846D12FE /* IdleTycoon */ = { + isa = PBXGroup; + children = ( + 5FE1271ED1E2428A97F68C7C /* IdleTycoonApp.swift */, + 8A9852AB19504334B5E8D19E /* Models */, + 499AB77B153A406BB14E50BB /* Services */, + DA6CB5D276EF4B9996067141 /* ViewModels */, + C44BD8B722874EAE98F6F774 /* Views */, + E80831150A2140D29AF378A1 /* Assets.xcassets */, + ); + path = IdleTycoon; + sourceTree = ""; + }; + 8A9852AB19504334B5E8D19E /* Models */ = { + isa = PBXGroup; + children = ( + FA9F726591794C569A591816 /* Business.swift */, + 5900AA1CD4024F2CA90A66A7 /* GameSave.swift */, + ); + path = Models; + sourceTree = ""; + }; + 499AB77B153A406BB14E50BB /* Services */ = { + isa = PBXGroup; + children = ( + E05057D30AE84D6DBC7653A8 /* Economy.swift */, + 8878A838C28A41769DAD9082 /* GameStore.swift */, + ); + path = Services; + sourceTree = ""; + }; + DA6CB5D276EF4B9996067141 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 1485C58B1379417EA7C459BA /* GameViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + C44BD8B722874EAE98F6F774 /* Views */ = { + isa = PBXGroup; + children = ( + 7BA512C6F8EF4C1DAF036FE4 /* Theme.swift */, + CEF5FEDC42394BAE9857FCB8 /* BusinessRowView.swift */, + 2576400E3843452290A04852 /* ContentView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 913CA0379F5E41CBA170EF9E /* IdleTycoonTests */ = { + isa = PBXGroup; + children = ( + 53A81DB5BDA5491E8549CAC5 /* EconomyTests.swift */, + ); + path = IdleTycoonTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0336EDDDD77C46269C12CC67 /* IdleTycoon */ = { + isa = PBXNativeTarget; + buildConfigurationList = 821F37FAED2242A3B4E1DD7B /* Build configuration list for PBXNativeTarget "IdleTycoon" */; + buildPhases = ( + 213D0AFFF0B34423B0E6D0F1 /* Sources */, + 503DD169140340089981C303 /* Frameworks */, + 04EAE7B1DBB44FC4A2F99473 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IdleTycoon; + productName = IdleTycoon; + productReference = AC5F26DAAAAB479F9DD920C5 /* IdleTycoon.app */; + productType = "com.apple.product-type.application"; + }; + AC5102D1CB9C4B6EBC1C35E3 /* IdleTycoonTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F132F95E22DA42E789702EF1 /* Build configuration list for PBXNativeTarget "IdleTycoonTests" */; + buildPhases = ( + 952035B92E5F42A9B6C53A31 /* Sources */, + 3FAEA05221DD47809E28CB21 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 88720EE8601D45AE9D910DB9 /* PBXTargetDependency */, + ); + name = IdleTycoonTests; + productName = IdleTycoonTests; + productReference = D452D2BD9FC94649A99949A4 /* IdleTycoonTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXContainerItemProxy section */ + 1022D035C5F0405A98D82BA8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0D78810A5F70473296A4A81C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0336EDDDD77C46269C12CC67; + remoteInfo = IdleTycoon; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + 88720EE8601D45AE9D910DB9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0336EDDDD77C46269C12CC67 /* IdleTycoon */; + targetProxy = 1022D035C5F0405A98D82BA8 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXProject section */ + 0D78810A5F70473296A4A81C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 442CF83211B74679A0F41080 /* Build configuration list for PBXProject "IdleTycoon" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 951F407D31CD4F32B9C67A55; + productRefGroup = 0F98AB2CBB6C4C0EAB6197B9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0336EDDDD77C46269C12CC67 /* IdleTycoon */, + AC5102D1CB9C4B6EBC1C35E3 /* IdleTycoonTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 04EAE7B1DBB44FC4A2F99473 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D7D0FE0D82E4FC6AC21748F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 213D0AFFF0B34423B0E6D0F1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D2ED4481D07F41BBA8234774 /* IdleTycoonApp.swift in Sources */, + BC9B84456FAC43F4857D9C05 /* Business.swift in Sources */, + 21DBE6C9A9614671A01415E5 /* GameSave.swift in Sources */, + 7C7D15BF4135464BA5D3686A /* Economy.swift in Sources */, + B8875E0DB115463EA676893E /* GameStore.swift in Sources */, + D1FE93A5A5A6458B87AA9202 /* GameViewModel.swift in Sources */, + 6718DF01B6EB4575A81ED92F /* Theme.swift in Sources */, + 6B6E691494334F01BD58F825 /* BusinessRowView.swift in Sources */, + 1BD0D5FD76B34CE284D1DA40 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 952035B92E5F42A9B6C53A31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 25FACB143E3D441AB18AC90C /* EconomyTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + DF0C99FAB21C4648975C0151 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_DYNAMIC_NO_PIC = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 98FEBE2C57714AC2B6E5D315 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_DYNAMIC_NO_PIC = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 5F22AADB2F9F4BC19000BEAB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Corner Empire"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoon; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5AACA9328A6C44BDA11B5C94 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Corner Empire"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoon; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + FFB12A7C5F0A4A698923C5C9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoonTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IdleTycoon.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/IdleTycoon"; + }; + name = Debug; + }; + 083E7F4E081C4F8BA916237F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoonTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IdleTycoon.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/IdleTycoon"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 442CF83211B74679A0F41080 /* Build configuration list for PBXProject "IdleTycoon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF0C99FAB21C4648975C0151 /* Debug */, + 98FEBE2C57714AC2B6E5D315 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 821F37FAED2242A3B4E1DD7B /* Build configuration list for PBXNativeTarget "IdleTycoon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5F22AADB2F9F4BC19000BEAB /* Debug */, + 5AACA9328A6C44BDA11B5C94 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F132F95E22DA42E789702EF1 /* Build configuration list for PBXNativeTarget "IdleTycoonTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFB12A7C5F0A4A698923C5C9 /* Debug */, + 083E7F4E081C4F8BA916237F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0D78810A5F70473296A4A81C /* Project object */; +} diff --git a/IdleTycoon/IdleTycoon.xcodeproj/xcshareddata/xcschemes/IdleTycoon.xcscheme b/IdleTycoon/IdleTycoon.xcodeproj/xcshareddata/xcschemes/IdleTycoon.xcscheme new file mode 100644 index 0000000..507a319 --- /dev/null +++ b/IdleTycoon/IdleTycoon.xcodeproj/xcshareddata/xcschemes/IdleTycoon.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IdleTycoon/IdleTycoon/Assets.xcassets/AccentColor.colorset/Contents.json b/IdleTycoon/IdleTycoon/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..27536e7 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.220", + "green" : "0.780", + "red" : "0.980" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IdleTycoon/IdleTycoon/Assets.xcassets/AppIcon.appiconset/Contents.json b/IdleTycoon/IdleTycoon/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IdleTycoon/IdleTycoon/Assets.xcassets/Contents.json b/IdleTycoon/IdleTycoon/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IdleTycoon/IdleTycoon/IdleTycoonApp.swift b/IdleTycoon/IdleTycoon/IdleTycoonApp.swift new file mode 100644 index 0000000..1554d5a --- /dev/null +++ b/IdleTycoon/IdleTycoon/IdleTycoonApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct IdleTycoonApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .preferredColorScheme(.dark) + } + } +} diff --git a/IdleTycoon/IdleTycoon/Models/Business.swift b/IdleTycoon/IdleTycoon/Models/Business.swift new file mode 100644 index 0000000..5a6a506 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Models/Business.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A purchasable generator that produces currency over time. +struct Business: Identifiable, Codable, Hashable, Sendable { + let id: String + let name: String + let blurb: String + let baseCost: Double + let baseIncomePerSecond: Double + /// Multiplier applied to cost after each purchase (classic idle curve). + let costGrowth: Double + var owned: Int + + func costToBuy(count: Int = 1) -> Double { + guard count > 0 else { return 0 } + // Sum of geometric series: base * r^owned * (r^n - 1) / (r - 1) + let r = costGrowth + let start = pow(r, Double(owned)) + if abs(r - 1) < 1e-12 { + return baseCost * start * Double(count) + } + return baseCost * start * (pow(r, Double(count)) - 1) / (r - 1) + } + + var incomePerSecond: Double { + baseIncomePerSecond * Double(owned) + } + + mutating func buy(count: Int = 1) { + owned += count + } +} + +enum BusinessCatalog { + /// Starter businesses for a small private tycoon — swap names/art later. + static func starterBusinesses() -> [Business] { + [ + Business( + id: "cart", + name: "Lemon Cart", + blurb: "A folding table and a pitcher.", + baseCost: 15, + baseIncomePerSecond: 0.1, + costGrowth: 1.15, + owned: 1 + ), + Business( + id: "stand", + name: "Corner Stand", + blurb: "Painted sign, real napkins.", + baseCost: 100, + baseIncomePerSecond: 0.5, + costGrowth: 1.15, + owned: 0 + ), + Business( + id: "cafe", + name: "Sidewalk Cafe", + blurb: "Two tables and a loyal regular.", + baseCost: 1_100, + baseIncomePerSecond: 4, + costGrowth: 1.14, + owned: 0 + ), + Business( + id: "truck", + name: "Food Truck", + blurb: "Wheels, a window, lunch rush.", + baseCost: 12_000, + baseIncomePerSecond: 30, + costGrowth: 1.13, + owned: 0 + ), + Business( + id: "hall", + name: "Market Hall", + blurb: "A whole aisle of your brand.", + baseCost: 130_000, + baseIncomePerSecond: 220, + costGrowth: 1.12, + owned: 0 + ), + Business( + id: "factory", + name: "Bottling Works", + blurb: "Cans clatter; the town notices.", + baseCost: 1_400_000, + baseIncomePerSecond: 1_500, + costGrowth: 1.12, + owned: 0 + ), + ] + } +} diff --git a/IdleTycoon/IdleTycoon/Models/GameSave.swift b/IdleTycoon/IdleTycoon/Models/GameSave.swift new file mode 100644 index 0000000..cf6c41a --- /dev/null +++ b/IdleTycoon/IdleTycoon/Models/GameSave.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Disk-friendly snapshot of progress. No accounts, no ads, no cloud. +struct GameSave: Codable, Equatable, Sendable { + var coins: Double + var lifetimeCoins: Double + var globalMultiplier: Double + var businesses: [Business] + var lastTick: Date + var totalTaps: Int + + static func fresh() -> GameSave { + GameSave( + coins: 0, + lifetimeCoins: 0, + globalMultiplier: 1, + businesses: BusinessCatalog.starterBusinesses(), + lastTick: .now, + totalTaps: 0 + ) + } +} + +enum GameBalance { + /// Manual tap income before multipliers. + static let tapBase: Double = 1 + /// Cap how long offline earnings accrue (8 hours). + static let maxOfflineSeconds: TimeInterval = 8 * 60 * 60 + /// Offline pays a fraction of online rate (keeps returning rewarding but not broken). + static let offlineEfficiency: Double = 0.5 + /// Soft prestige-style multiplier bump cost (very light for a starter). + static let multiplierUpgradeBaseCost: Double = 50_000 + static let multiplierUpgradeGrowth: Double = 2.5 + static let multiplierUpgradeStep: Double = 0.25 +} diff --git a/IdleTycoon/IdleTycoon/Services/Economy.swift b/IdleTycoon/IdleTycoon/Services/Economy.swift new file mode 100644 index 0000000..dc7f41c --- /dev/null +++ b/IdleTycoon/IdleTycoon/Services/Economy.swift @@ -0,0 +1,94 @@ +import Foundation + +enum Economy { + static func incomePerSecond(save: GameSave) -> Double { + let raw = save.businesses.reduce(0) { $0 + $1.incomePerSecond } + return raw * save.globalMultiplier + } + + static func tapValue(save: GameSave) -> Double { + GameBalance.tapBase * save.globalMultiplier + } + + static func applyPassive(save: inout GameSave, delta: TimeInterval, efficiency: Double = 1) { + guard delta > 0 else { return } + let earned = incomePerSecond(save: save) * delta * efficiency + save.coins += earned + save.lifetimeCoins += earned + } + + /// Settle time away from the app (capped + discounted). + @discardableResult + static func collectOfflineEarnings(save: inout GameSave, now: Date = .now) -> Double { + let elapsed = now.timeIntervalSince(save.lastTick) + let capped = min(max(0, elapsed), GameBalance.maxOfflineSeconds) + let before = save.coins + applyPassive(save: &save, delta: capped, efficiency: GameBalance.offlineEfficiency) + save.lastTick = now + return save.coins - before + } + + static func canAfford(_ cost: Double, save: GameSave) -> Bool { + save.coins + 1e-9 >= cost + } + + @discardableResult + static func buyBusiness(id: String, count: Int = 1, save: inout GameSave) -> Bool { + guard count > 0, + let index = save.businesses.firstIndex(where: { $0.id == id }) + else { return false } + + let cost = save.businesses[index].costToBuy(count: count) + guard canAfford(cost, save: save) else { return false } + + save.coins -= cost + save.businesses[index].buy(count: count) + return true + } + + static func multiplierUpgradeCost(save: GameSave) -> Double { + let steps = max(0, (save.globalMultiplier - 1) / GameBalance.multiplierUpgradeStep) + return GameBalance.multiplierUpgradeBaseCost + * pow(GameBalance.multiplierUpgradeGrowth, steps) + } + + @discardableResult + static func buyMultiplier(save: inout GameSave) -> Bool { + let cost = multiplierUpgradeCost(save: save) + guard canAfford(cost, save: save) else { return false } + save.coins -= cost + save.globalMultiplier += GameBalance.multiplierUpgradeStep + return true + } + + static func tap(save: inout GameSave) { + let value = tapValue(save: save) + save.coins += value + save.lifetimeCoins += value + save.totalTaps += 1 + } +} + +enum CurrencyFormat { + private static let suffixes = ["", "K", "M", "B", "T", "Qa", "Qi", "Sx"] + + static func coins(_ value: Double) -> String { + let sign = value < 0 ? "-" : "" + var n = abs(value) + var idx = 0 + while n >= 1000 && idx < suffixes.count - 1 { + n /= 1000 + idx += 1 + } + if idx == 0 { + if n >= 100 { return "\(sign)\(Int(n.rounded()))" } + if n >= 10 { return String(format: "%@%.1f", sign, n) } + return String(format: "%@%.2f", sign, n) + } + return String(format: "%@%.2f%@", sign, n, suffixes[idx]) + } + + static func rate(_ value: Double) -> String { + "\(coins(value))/s" + } +} diff --git a/IdleTycoon/IdleTycoon/Services/GameStore.swift b/IdleTycoon/IdleTycoon/Services/GameStore.swift new file mode 100644 index 0000000..5188290 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Services/GameStore.swift @@ -0,0 +1,37 @@ +import Foundation + +protocol GamePersisting: Sendable { + func load() -> GameSave? + func save(_ state: GameSave) + func clear() +} + +/// Local JSON in Application Support. Private play — no network, no ads SDK. +struct FileGameStore: GamePersisting { + private let fileURL: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(filename: String = "idle-tycoon-save.json") { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.fileURL = dir.appendingPathComponent(filename) + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func load() -> GameSave? { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? decoder.decode(GameSave.self, from: data) + } + + func save(_ state: GameSave) { + guard let data = try? encoder.encode(state) else { return } + try? data.write(to: fileURL, options: [.atomic]) + } + + func clear() { + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/IdleTycoon/IdleTycoon/ViewModels/GameViewModel.swift b/IdleTycoon/IdleTycoon/ViewModels/GameViewModel.swift new file mode 100644 index 0000000..f24845a --- /dev/null +++ b/IdleTycoon/IdleTycoon/ViewModels/GameViewModel.swift @@ -0,0 +1,99 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class GameViewModel { + private(set) var save: GameSave + private(set) var offlineBanner: String? + private(set) var lastSavedAt: Date? + + private let store: any GamePersisting + private var autosaveTask: Task? + private var tickTask: Task? + + var incomePerSecond: Double { Economy.incomePerSecond(save: save) } + var tapValue: Double { Economy.tapValue(save: save) } + var multiplierCost: Double { Economy.multiplierUpgradeCost(save: save) } + + init(store: any GamePersisting = FileGameStore()) { + self.store = store + if var loaded = store.load() { + let earned = Economy.collectOfflineEarnings(save: &loaded) + self.save = loaded + if earned > 0.01 { + self.offlineBanner = "While you were away: +\(CurrencyFormat.coins(earned))" + } + store.save(loaded) + self.lastSavedAt = .now + } else { + self.save = .fresh() + } + startAutosave() + startTicker() + } + + deinit { + autosaveTask?.cancel() + tickTask?.cancel() + } + + func dismissOfflineBanner() { + offlineBanner = nil + } + + func tick(delta: TimeInterval) { + Economy.applyPassive(save: &save, delta: delta) + save.lastTick = .now + } + + func tapStand() { + Economy.tap(save: &save) + } + + func buy(_ businessID: String, count: Int = 1) { + _ = Economy.buyBusiness(id: businessID, count: count, save: &save) + persist() + } + + func buyMultiplier() { + _ = Economy.buyMultiplier(save: &save) + persist() + } + + func resetProgress() { + store.clear() + save = .fresh() + offlineBanner = nil + persist() + } + + func persist() { + store.save(save) + lastSavedAt = .now + } + + private func startAutosave() { + autosaveTask?.cancel() + autosaveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + self?.persist() + } + } + } + + private func startTicker() { + tickTask?.cancel() + tickTask = Task { [weak self] in + var last = Date.now + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(100)) + let now = Date.now + let delta = now.timeIntervalSince(last) + last = now + self?.tick(delta: min(delta, 1)) + } + } + } +} diff --git a/IdleTycoon/IdleTycoon/Views/BusinessRowView.swift b/IdleTycoon/IdleTycoon/Views/BusinessRowView.swift new file mode 100644 index 0000000..36a6afa --- /dev/null +++ b/IdleTycoon/IdleTycoon/Views/BusinessRowView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct BusinessRowView: View { + let business: Business + let coins: Double + let multiplier: Double + let onBuy: () -> Void + + private var cost: Double { business.costToBuy() } + private var affordable: Bool { coins + 1e-9 >= cost } + private var rate: Double { business.incomePerSecond * multiplier } + + var body: some View { + HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(business.name) + .font(.system(.headline, design: .rounded).weight(.bold)) + .foregroundStyle(TycoonTheme.foam) + Text(business.blurb) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(TycoonTheme.foam.opacity(0.7)) + .lineLimit(2) + Text("Owned \(business.owned) · \(CurrencyFormat.rate(rate))") + .font(.system(.caption2, design: .rounded).monospacedDigit()) + .foregroundStyle(TycoonTheme.moss) + } + + Spacer(minLength: 8) + + Button(action: onBuy) { + VStack(spacing: 2) { + Text("Buy") + .font(.system(.caption, design: .rounded).weight(.bold)) + Text(CurrencyFormat.coins(cost)) + .font(.system(.caption2, design: .rounded).monospacedDigit()) + } + .foregroundStyle(affordable ? TycoonTheme.ink : TycoonTheme.foam.opacity(0.45)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(affordable ? TycoonTheme.citrus : TycoonTheme.foam.opacity(0.08)) + ) + } + .buttonStyle(.plain) + .disabled(!affordable) + .accessibilityLabel("Buy \(business.name) for \(CurrencyFormat.coins(cost))") + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(TycoonTheme.foam.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(TycoonTheme.foam.opacity(0.08), lineWidth: 1) + ) + ) + } +} diff --git a/IdleTycoon/IdleTycoon/Views/ContentView.swift b/IdleTycoon/IdleTycoon/Views/ContentView.swift new file mode 100644 index 0000000..1b05190 --- /dev/null +++ b/IdleTycoon/IdleTycoon/Views/ContentView.swift @@ -0,0 +1,182 @@ +import SwiftUI + +struct ContentView: View { + @State private var game = GameViewModel() + @State private var pulse = false + + var body: some View { + ZStack { + TycoonTheme.background.ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + header + if let banner = game.offlineBanner { + offlineCard(banner) + } + tapStage + multiplierRow + businessList + footer + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Corner Empire") + .font(.system(size: 34, weight: .heavy, design: .rounded)) + .foregroundStyle(TycoonTheme.citrus) + Text("A private idle tycoon — no ads, no store, just lemonade math.") + .font(.system(.subheadline, design: .rounded)) + .foregroundStyle(TycoonTheme.foam.opacity(0.75)) + + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(CurrencyFormat.coins(game.save.coins)) + .font(.system(size: 40, weight: .bold, design: .rounded).monospacedDigit()) + .foregroundStyle(TycoonTheme.foam) + .contentTransition(.numericText()) + Text(CurrencyFormat.rate(game.incomePerSecond)) + .font(.system(.headline, design: .rounded).monospacedDigit()) + .foregroundStyle(TycoonTheme.moss) + } + .padding(.top, 4) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var tapStage: some View { + Button(action: { + game.tapStand() + withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) { + pulse.toggle() + } + }) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [TycoonTheme.citrus, TycoonTheme.pulp.opacity(0.85)], + center: .center, + startRadius: 10, + endRadius: 120 + ) + ) + .frame(width: 180, height: 180) + .scaleEffect(pulse ? 1.04 : 1.0) + .shadow(color: TycoonTheme.citrus.opacity(0.35), radius: 24, y: 8) + + VStack(spacing: 6) { + Text("Squeeze") + .font(.system(.title2, design: .rounded).weight(.bold)) + Text("+\(CurrencyFormat.coins(game.tapValue))") + .font(.system(.subheadline, design: .rounded).monospacedDigit()) + } + .foregroundStyle(TycoonTheme.ink) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .accessibilityLabel("Squeeze for coins") + } + + private var multiplierRow: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Global squeeze ×\(String(format: "%.2f", game.save.globalMultiplier))") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(TycoonTheme.foam) + Text("Boosts taps and every business.") + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(TycoonTheme.foam.opacity(0.65)) + } + Spacer() + Button("Upgrade \(CurrencyFormat.coins(game.multiplierCost))") { + game.buyMultiplier() + } + .font(.system(.caption, design: .rounded).weight(.bold)) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + Capsule() + .fill( + game.save.coins >= game.multiplierCost + ? TycoonTheme.moss + : TycoonTheme.foam.opacity(0.12) + ) + ) + .foregroundStyle( + game.save.coins >= game.multiplierCost + ? TycoonTheme.ink + : TycoonTheme.foam.opacity(0.45) + ) + .disabled(game.save.coins < game.multiplierCost) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(TycoonTheme.foam.opacity(0.05)) + ) + } + + private var businessList: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Businesses") + .font(.system(.title3, design: .rounded).weight(.bold)) + .foregroundStyle(TycoonTheme.foam) + + ForEach(game.save.businesses) { business in + BusinessRowView( + business: business, + coins: game.save.coins, + multiplier: game.save.globalMultiplier + ) { + game.buy(business.id) + } + } + } + } + + private var footer: some View { + VStack(spacing: 8) { + if let saved = game.lastSavedAt { + Text("Saved \(saved.formatted(date: .omitted, time: .standard))") + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(TycoonTheme.foam.opacity(0.45)) + } + Button("Reset private save", role: .destructive) { + game.resetProgress() + } + .font(.system(.caption, design: .rounded)) + .foregroundStyle(TycoonTheme.pulp) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity) + } + + private func offlineCard(_ text: String) -> some View { + HStack { + Text(text) + .font(.system(.subheadline, design: .rounded).weight(.medium)) + .foregroundStyle(TycoonTheme.ink) + Spacer() + Button("OK") { game.dismissOfflineBanner() } + .font(.system(.caption, design: .rounded).weight(.bold)) + .foregroundStyle(TycoonTheme.ink) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(TycoonTheme.citrus) + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } +} + +#Preview { + ContentView() +} diff --git a/IdleTycoon/IdleTycoon/Views/Theme.swift b/IdleTycoon/IdleTycoon/Views/Theme.swift new file mode 100644 index 0000000..394a63c --- /dev/null +++ b/IdleTycoon/IdleTycoon/Views/Theme.swift @@ -0,0 +1,19 @@ +import SwiftUI + +enum TycoonTheme { + static let backgroundTop = Color(red: 0.07, green: 0.18, blue: 0.20) + static let backgroundBottom = Color(red: 0.12, green: 0.28, blue: 0.26) + static let citrus = Color(red: 0.98, green: 0.78, blue: 0.22) + static let pulp = Color(red: 0.95, green: 0.55, blue: 0.20) + static let foam = Color(red: 0.93, green: 0.96, blue: 0.94) + static let moss = Color(red: 0.35, green: 0.72, blue: 0.55) + static let ink = Color(red: 0.05, green: 0.12, blue: 0.14) + + static var background: LinearGradient { + LinearGradient( + colors: [backgroundTop, backgroundBottom], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} diff --git a/IdleTycoon/IdleTycoonTests/EconomyTests.swift b/IdleTycoon/IdleTycoonTests/EconomyTests.swift new file mode 100644 index 0000000..bfb9edf --- /dev/null +++ b/IdleTycoon/IdleTycoonTests/EconomyTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import IdleTycoon + +final class EconomyTests: XCTestCase { + func testGeometricBuyCostMatchesSeries() { + var business = Business( + id: "t", + name: "Test", + blurb: "", + baseCost: 100, + baseIncomePerSecond: 1, + costGrowth: 1.15, + owned: 0 + ) + + let one = business.costToBuy(count: 1) + XCTAssertEqual(one, 100, accuracy: 1e-9) + + let three = business.costToBuy(count: 3) + let expected = 100 + 100 * 1.15 + 100 * 1.15 * 1.15 + XCTAssertEqual(three, expected, accuracy: 1e-9) + + business.buy(count: 2) + XCTAssertEqual(business.owned, 2) + XCTAssertEqual(business.costToBuy(), 100 * pow(1.15, 2), accuracy: 1e-9) + } + + func testPassiveIncomeUsesMultiplier() { + var save = GameSave.fresh() + save.globalMultiplier = 2 + // Starter owns 1 lemon cart @ 0.1/s → 0.2/s with multiplier + Economy.applyPassive(save: &save, delta: 10) + XCTAssertEqual(save.coins, 2.0, accuracy: 1e-9) + } + + func testBuyRejectsWhenBroke() { + var save = GameSave.fresh() + save.coins = 10 + let ok = Economy.buyBusiness(id: "stand", save: &save) + XCTAssertFalse(ok) + XCTAssertEqual(save.businesses.first { $0.id == "stand" }?.owned, 0) + } + + func testOfflineEarningsCappedAndDiscounted() { + var save = GameSave.fresh() + save.lastTick = Date().addingTimeInterval(-24 * 60 * 60) + let income = Economy.incomePerSecond(save: save) + let earned = Economy.collectOfflineEarnings(save: &save) + let expected = income * GameBalance.maxOfflineSeconds * GameBalance.offlineEfficiency + XCTAssertEqual(earned, expected, accuracy: 1e-6) + } + + func testCurrencyFormatSuffixes() { + XCTAssertEqual(CurrencyFormat.coins(12.34), "12.3") + XCTAssertEqual(CurrencyFormat.coins(9.87), "9.87") + XCTAssertEqual(CurrencyFormat.coins(1_500), "1.50K") + XCTAssertEqual(CurrencyFormat.coins(2_000_000), "2.00M") + } +} diff --git a/IdleTycoon/README.md b/IdleTycoon/README.md new file mode 100644 index 0000000..e0b29ba --- /dev/null +++ b/IdleTycoon/README.md @@ -0,0 +1,75 @@ +# Corner Empire (Idle Tycoon) + +Private SwiftUI idle tycoon for iPhone — **no ads, no IAP, no network**. Progress saves locally as JSON. + +This is a starter you can reskin: same loop as the “same game, different art” genre kits, but your own code and balance. + +## Open in Xcode (Mac) + +1. Open `IdleTycoon/IdleTycoon.xcodeproj`. +2. Select an iPhone simulator or your device. +3. Set your Team under **Signing & Capabilities** (for device installs). +4. Run (⌘R). + +Optional: regenerate the project file after adding sources: + +```bash +python3 scripts/generate_xcodeproj.py +``` + +## What’s included + +| Piece | Role | +| --- | --- | +| `Business` + `BusinessCatalog` | Generators with geometric cost growth | +| `Economy` | Tap, passive income, offline cap, global multiplier | +| `FileGameStore` | Local JSON in Application Support | +| `GameViewModel` | 10 Hz tick + autosave | +| SwiftUI views | One-screen tycoon UI (“Corner Empire”) | + +## Core loop (idle formula) + +- Business cost for the next `n` buys: geometric series on `baseCost * costGrowth^owned` +- Income/sec = `sum(baseIncome * owned) * globalMultiplier` +- Offline = min(away, 8h) × income/sec × 0.5 +- Tap = `1 * globalMultiplier` + +Tune numbers in `Models/GameSave.swift` (`GameBalance`) and `BusinessCatalog`. + +## Private play, no ads + +There is no AdMob (or any) SDK. Keep it that way for a personal build. + +- **Simulator / your device (dev signing):** fine for private use +- **TestFlight / friends:** Apple Developer Program +- **App Store:** only if you want public distribution later + +## Open references (SwiftUI idle / clicker) + +Useful examples to read (not vendored here): + +- [bennaro/ClickerGame](https://github.com/bennaro/clickergame) — Cookie Clicker–style SwiftUI +- [XanderJenei/ToggleTycoon](https://github.com/XanderJenei/ToggleTycoon) — incremental SwiftUI prototype +- [mat-sz/SwiftUI-Clicker](https://github.com/mat-sz/SwiftUI-Clicker) — small SwiftUI incremental demo + +## Tests + +On a Mac with Xcode: + +```bash +xcodebuild -scheme IdleTycoon -destination 'platform=iOS Simulator,name=iPhone 16' test +``` + +On this Linux environment, economy math is mirrored in `scripts/test_economy.py`: + +```bash +python3 scripts/test_economy.py +``` + +## Next levers (when you want to grow it) + +1. Prestige / prestige currency that resets businesses +2. Managers that auto-buy or multiply one business +3. Simple art pack (SF Symbols → custom assets) +4. Haptics on squeeze / big purchases +5. Achievements (lifetime coins milestones) diff --git a/IdleTycoon/scripts/generate_xcodeproj.py b/IdleTycoon/scripts/generate_xcodeproj.py new file mode 100644 index 0000000..a91239d --- /dev/null +++ b/IdleTycoon/scripts/generate_xcodeproj.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +"""Generate a minimal iOS Xcode project for Corner Empire / IdleTycoon.""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = ROOT / "IdleTycoon.xcodeproj" +SOURCES = [ + "IdleTycoon/IdleTycoonApp.swift", + "IdleTycoon/Models/Business.swift", + "IdleTycoon/Models/GameSave.swift", + "IdleTycoon/Services/Economy.swift", + "IdleTycoon/Services/GameStore.swift", + "IdleTycoon/ViewModels/GameViewModel.swift", + "IdleTycoon/Views/Theme.swift", + "IdleTycoon/Views/BusinessRowView.swift", + "IdleTycoon/Views/ContentView.swift", +] +TESTS = [ + "IdleTycoonTests/EconomyTests.swift", +] +ASSETS = "IdleTycoon/Assets.xcassets" + + +def uid() -> str: + return uuid.uuid4().hex[:24].upper() + + +def main() -> None: + ids = { + "project": uid(), + "app_target": uid(), + "test_target": uid(), + "sources_phase": uid(), + "resources_phase": uid(), + "frameworks_phase": uid(), + "test_sources_phase": uid(), + "test_frameworks_phase": uid(), + "app_product": uid(), + "test_product": uid(), + "app_config_list": uid(), + "test_config_list": uid(), + "project_config_list": uid(), + "debug_project": uid(), + "release_project": uid(), + "debug_app": uid(), + "release_app": uid(), + "debug_test": uid(), + "release_test": uid(), + "group_root": uid(), + "group_app": uid(), + "group_models": uid(), + "group_services": uid(), + "group_viewmodels": uid(), + "group_views": uid(), + "group_tests": uid(), + "group_products": uid(), + "assets": uid(), + "test_dependency": uid(), + "test_container": uid(), + } + + file_ids = {path: uid() for path in SOURCES + TESTS} + build_ids = {path: uid() for path in SOURCES + TESTS} + assets_build = uid() + + def build_file(path: str) -> str: + return ( + f"\t\t{build_ids[path]} /* {Path(path).name} in Sources */ = " + f"{{isa = PBXBuildFile; fileRef = {file_ids[path]} /* {Path(path).name} */; }};" + ) + + def file_ref(path: str) -> str: + return ( + f"\t\t{file_ids[path]} /* {Path(path).name} */ = " + f"{{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; " + f"path = {Path(path).name}; sourceTree = \"\"; }};" + ) + + lines: list[str] = [] + lines.append("// !$*UTF8*$!") + lines.append("{") + lines.append("\tarchiveVersion = 1;") + lines.append("\tclasses = {") + lines.append("\t};") + lines.append("\tobjectVersion = 56;") + lines.append("\tobjects = {") + lines.append("") + lines.append("/* Begin PBXBuildFile section */") + for path in SOURCES: + lines.append(build_file(path)) + for path in TESTS: + lines.append(build_file(path)) + lines.append( + f"\t\t{assets_build} /* Assets.xcassets in Resources */ = " + f"{{isa = PBXBuildFile; fileRef = {ids['assets']} /* Assets.xcassets */; }};" + ) + lines.append("/* End PBXBuildFile section */") + lines.append("") + lines.append("/* Begin PBXFileReference section */") + lines.append( + f"\t\t{ids['app_product']} /* IdleTycoon.app */ = " + "{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; " + "path = IdleTycoon.app; sourceTree = BUILT_PRODUCTS_DIR; };" + ) + lines.append( + f"\t\t{ids['test_product']} /* IdleTycoonTests.xctest */ = " + "{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; " + "path = IdleTycoonTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };" + ) + for path in SOURCES + TESTS: + lines.append(file_ref(path)) + lines.append( + f"\t\t{ids['assets']} /* Assets.xcassets */ = " + "{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; " + "path = Assets.xcassets; sourceTree = \"\"; };" + ) + lines.append("/* End PBXFileReference section */") + lines.append("") + lines.append("/* Begin PBXFrameworksBuildPhase section */") + lines.append(f"\t\t{ids['frameworks_phase']} /* Frameworks */ = {{") + lines.append("\t\t\tisa = PBXFrameworksBuildPhase;") + lines.append("\t\t\tbuildActionMask = 2147483647;") + lines.append("\t\t\tfiles = (") + lines.append("\t\t\t);") + lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") + lines.append("\t\t};") + lines.append(f"\t\t{ids['test_frameworks_phase']} /* Frameworks */ = {{") + lines.append("\t\t\tisa = PBXFrameworksBuildPhase;") + lines.append("\t\t\tbuildActionMask = 2147483647;") + lines.append("\t\t\tfiles = (") + lines.append("\t\t\t);") + lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") + lines.append("\t\t};") + lines.append("/* End PBXFrameworksBuildPhase section */") + lines.append("") + lines.append("/* Begin PBXGroup section */") + lines.append(f"\t\t{ids['group_root']} = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + lines.append(f"\t\t\t\t{ids['group_app']} /* IdleTycoon */,") + lines.append(f"\t\t\t\t{ids['group_tests']} /* IdleTycoonTests */,") + lines.append(f"\t\t\t\t{ids['group_products']} /* Products */,") + lines.append("\t\t\t);") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_products']} /* Products */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + lines.append(f"\t\t\t\t{ids['app_product']} /* IdleTycoon.app */,") + lines.append(f"\t\t\t\t{ids['test_product']} /* IdleTycoonTests.xctest */,") + lines.append("\t\t\t);") + lines.append("\t\t\tname = Products;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_app']} /* IdleTycoon */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + lines.append(f"\t\t\t\t{file_ids['IdleTycoon/IdleTycoonApp.swift']} /* IdleTycoonApp.swift */,") + lines.append(f"\t\t\t\t{ids['group_models']} /* Models */,") + lines.append(f"\t\t\t\t{ids['group_services']} /* Services */,") + lines.append(f"\t\t\t\t{ids['group_viewmodels']} /* ViewModels */,") + lines.append(f"\t\t\t\t{ids['group_views']} /* Views */,") + lines.append(f"\t\t\t\t{ids['assets']} /* Assets.xcassets */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = IdleTycoon;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_models']} /* Models */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + for path in [p for p in SOURCES if "/Models/" in p]: + lines.append(f"\t\t\t\t{file_ids[path]} /* {Path(path).name} */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = Models;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_services']} /* Services */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + for path in [p for p in SOURCES if "/Services/" in p]: + lines.append(f"\t\t\t\t{file_ids[path]} /* {Path(path).name} */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = Services;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_viewmodels']} /* ViewModels */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + for path in [p for p in SOURCES if "/ViewModels/" in p]: + lines.append(f"\t\t\t\t{file_ids[path]} /* {Path(path).name} */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = ViewModels;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_views']} /* Views */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + for path in [p for p in SOURCES if "/Views/" in p]: + lines.append(f"\t\t\t\t{file_ids[path]} /* {Path(path).name} */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = Views;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['group_tests']} /* IdleTycoonTests */ = {{") + lines.append("\t\t\tisa = PBXGroup;") + lines.append("\t\t\tchildren = (") + for path in TESTS: + lines.append(f"\t\t\t\t{file_ids[path]} /* {Path(path).name} */,") + lines.append("\t\t\t);") + lines.append("\t\t\tpath = IdleTycoonTests;") + lines.append("\t\t\tsourceTree = \"\";") + lines.append("\t\t};") + lines.append("/* End PBXGroup section */") + lines.append("") + lines.append("/* Begin PBXNativeTarget section */") + lines.append(f"\t\t{ids['app_target']} /* IdleTycoon */ = {{") + lines.append("\t\t\tisa = PBXNativeTarget;") + lines.append("\t\t\tbuildConfigurationList = " + f"{ids['app_config_list']} /* Build configuration list for PBXNativeTarget \"IdleTycoon\" */;") + lines.append("\t\t\tbuildPhases = (") + lines.append(f"\t\t\t\t{ids['sources_phase']} /* Sources */,") + lines.append(f"\t\t\t\t{ids['frameworks_phase']} /* Frameworks */,") + lines.append(f"\t\t\t\t{ids['resources_phase']} /* Resources */,") + lines.append("\t\t\t);") + lines.append("\t\t\tbuildRules = (") + lines.append("\t\t\t);") + lines.append("\t\t\tdependencies = (") + lines.append("\t\t\t);") + lines.append("\t\t\tname = IdleTycoon;") + lines.append("\t\t\tproductName = IdleTycoon;") + lines.append(f"\t\t\tproductReference = {ids['app_product']} /* IdleTycoon.app */;") + lines.append("\t\t\tproductType = \"com.apple.product-type.application\";") + lines.append("\t\t};") + lines.append(f"\t\t{ids['test_target']} /* IdleTycoonTests */ = {{") + lines.append("\t\t\tisa = PBXNativeTarget;") + lines.append("\t\t\tbuildConfigurationList = " + f"{ids['test_config_list']} /* Build configuration list for PBXNativeTarget \"IdleTycoonTests\" */;") + lines.append("\t\t\tbuildPhases = (") + lines.append(f"\t\t\t\t{ids['test_sources_phase']} /* Sources */,") + lines.append(f"\t\t\t\t{ids['test_frameworks_phase']} /* Frameworks */,") + lines.append("\t\t\t);") + lines.append("\t\t\tbuildRules = (") + lines.append("\t\t\t);") + lines.append("\t\t\tdependencies = (") + lines.append(f"\t\t\t\t{ids['test_dependency']} /* PBXTargetDependency */,") + lines.append("\t\t\t);") + lines.append("\t\t\tname = IdleTycoonTests;") + lines.append("\t\t\tproductName = IdleTycoonTests;") + lines.append(f"\t\t\tproductReference = {ids['test_product']} /* IdleTycoonTests.xctest */;") + lines.append("\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";") + lines.append("\t\t};") + lines.append("/* End PBXNativeTarget section */") + lines.append("") + lines.append("/* Begin PBXContainerItemProxy section */") + lines.append(f"\t\t{ids['test_container']} /* PBXContainerItemProxy */ = {{") + lines.append("\t\t\tisa = PBXContainerItemProxy;") + lines.append(f"\t\t\tcontainerPortal = {ids['project']} /* Project object */;") + lines.append("\t\t\tproxyType = 1;") + lines.append(f"\t\t\tremoteGlobalIDString = {ids['app_target']};") + lines.append("\t\t\tremoteInfo = IdleTycoon;") + lines.append("\t\t};") + lines.append("/* End PBXContainerItemProxy section */") + lines.append("") + lines.append("/* Begin PBXTargetDependency section */") + lines.append(f"\t\t{ids['test_dependency']} /* PBXTargetDependency */ = {{") + lines.append("\t\t\tisa = PBXTargetDependency;") + lines.append(f"\t\t\ttarget = {ids['app_target']} /* IdleTycoon */;") + lines.append(f"\t\t\ttargetProxy = {ids['test_container']} /* PBXContainerItemProxy */;") + lines.append("\t\t};") + lines.append("/* End PBXTargetDependency section */") + lines.append("") + lines.append("/* Begin PBXProject section */") + lines.append(f"\t\t{ids['project']} /* Project object */ = {{") + lines.append("\t\t\tisa = PBXProject;") + lines.append("\t\t\tattributes = {") + lines.append("\t\t\t\tBuildIndependentTargetsInParallel = 1;") + lines.append("\t\t\t\tLastSwiftUpdateCheck = 1600;") + lines.append("\t\t\t\tLastUpgradeCheck = 1600;") + lines.append("\t\t\t};") + lines.append("\t\t\tbuildConfigurationList = " + f"{ids['project_config_list']} /* Build configuration list for PBXProject \"IdleTycoon\" */;") + lines.append("\t\t\tcompatibilityVersion = \"Xcode 14.0\";") + lines.append("\t\t\tdevelopmentRegion = en;") + lines.append("\t\t\thasScannedForEncodings = 0;") + lines.append("\t\t\tknownRegions = (") + lines.append("\t\t\t\ten,") + lines.append("\t\t\t\tBase,") + lines.append("\t\t\t);") + lines.append(f"\t\t\tmainGroup = {ids['group_root']};") + lines.append(f"\t\t\tproductRefGroup = {ids['group_products']} /* Products */;") + lines.append("\t\t\tprojectDirPath = \"\";") + lines.append("\t\t\tprojectRoot = \"\";") + lines.append("\t\t\ttargets = (") + lines.append(f"\t\t\t\t{ids['app_target']} /* IdleTycoon */,") + lines.append(f"\t\t\t\t{ids['test_target']} /* IdleTycoonTests */,") + lines.append("\t\t\t);") + lines.append("\t\t};") + lines.append("/* End PBXProject section */") + lines.append("") + lines.append("/* Begin PBXResourcesBuildPhase section */") + lines.append(f"\t\t{ids['resources_phase']} /* Resources */ = {{") + lines.append("\t\t\tisa = PBXResourcesBuildPhase;") + lines.append("\t\t\tbuildActionMask = 2147483647;") + lines.append("\t\t\tfiles = (") + lines.append(f"\t\t\t\t{assets_build} /* Assets.xcassets in Resources */,") + lines.append("\t\t\t);") + lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") + lines.append("\t\t};") + lines.append("/* End PBXResourcesBuildPhase section */") + lines.append("") + lines.append("/* Begin PBXSourcesBuildPhase section */") + lines.append(f"\t\t{ids['sources_phase']} /* Sources */ = {{") + lines.append("\t\t\tisa = PBXSourcesBuildPhase;") + lines.append("\t\t\tbuildActionMask = 2147483647;") + lines.append("\t\t\tfiles = (") + for path in SOURCES: + lines.append(f"\t\t\t\t{build_ids[path]} /* {Path(path).name} in Sources */,") + lines.append("\t\t\t);") + lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") + lines.append("\t\t};") + lines.append(f"\t\t{ids['test_sources_phase']} /* Sources */ = {{") + lines.append("\t\t\tisa = PBXSourcesBuildPhase;") + lines.append("\t\t\tbuildActionMask = 2147483647;") + lines.append("\t\t\tfiles = (") + for path in TESTS: + lines.append(f"\t\t\t\t{build_ids[path]} /* {Path(path).name} in Sources */,") + lines.append("\t\t\t);") + lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") + lines.append("\t\t};") + lines.append("/* End PBXSourcesBuildPhase section */") + lines.append("") + lines.append("/* Begin XCBuildConfiguration section */") + + def project_config(cid: str, name: str) -> None: + lines.append(f"\t\t{cid} /* {name} */ = {{") + lines.append("\t\t\tisa = XCBuildConfiguration;") + lines.append("\t\t\tbuildSettings = {") + lines.append("\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;") + lines.append("\t\t\t\tCLANG_ENABLE_MODULES = YES;") + lines.append("\t\t\t\tCOPY_PHASE_STRIP = NO;") + lines.append("\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;") + lines.append("\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;") + lines.append("\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;") + lines.append("\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;") + lines.append("\t\t\t\tONLY_ACTIVE_ARCH = YES;") + lines.append("\t\t\t\tSDKROOT = iphoneos;") + lines.append("\t\t\t\tSWIFT_VERSION = 5.0;") + if name == "Debug": + lines.append("\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;") + lines.append("\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";") + else: + lines.append("\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;") + lines.append("\t\t\t};") + lines.append(f"\t\t\tname = {name};") + lines.append("\t\t};") + + def app_config(cid: str, name: str) -> None: + lines.append(f"\t\t{cid} /* {name} */ = {{") + lines.append("\t\t\tisa = XCBuildConfiguration;") + lines.append("\t\t\tbuildSettings = {") + lines.append("\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;") + lines.append("\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;") + lines.append("\t\t\t\tCODE_SIGN_STYLE = Automatic;") + lines.append("\t\t\t\tCURRENT_PROJECT_VERSION = 1;") + lines.append("\t\t\t\tDEVELOPMENT_TEAM = \"\";") + lines.append("\t\t\t\tENABLE_PREVIEWS = YES;") + lines.append("\t\t\t\tENABLE_TESTABILITY = YES;") + lines.append("\t\t\t\tGENERATE_INFOPLIST_FILE = YES;") + lines.append("\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = \"Corner Empire\";") + lines.append("\t\t\t\tINFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;") + lines.append("\t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES;") + lines.append( + "\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = " + "\"UIInterfaceOrientationPortrait\";" + ) + lines.append("\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (") + lines.append("\t\t\t\t\t\"$(inherited)\",") + lines.append("\t\t\t\t\t\"@executable_path/Frameworks\",") + lines.append("\t\t\t\t);") + lines.append("\t\t\t\tMARKETING_VERSION = 0.1.0;") + lines.append("\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoon;") + lines.append("\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";") + lines.append("\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";") + lines.append("\t\t\t\tSUPPORTS_MACCATALYST = NO;") + lines.append("\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;") + lines.append("\t\t\t\tSWIFT_VERSION = 5.0;") + lines.append("\t\t\t\tTARGETED_DEVICE_FAMILY = 1;") + lines.append("\t\t\t};") + lines.append(f"\t\t\tname = {name};") + lines.append("\t\t};") + + def test_config(cid: str, name: str) -> None: + lines.append(f"\t\t{cid} /* {name} */ = {{") + lines.append("\t\t\tisa = XCBuildConfiguration;") + lines.append("\t\t\tbuildSettings = {") + lines.append("\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";") + lines.append("\t\t\t\tCODE_SIGN_STYLE = Automatic;") + lines.append("\t\t\t\tCURRENT_PROJECT_VERSION = 1;") + lines.append("\t\t\t\tGENERATE_INFOPLIST_FILE = YES;") + lines.append("\t\t\t\tMARKETING_VERSION = 0.1.0;") + lines.append("\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.IdleTycoonTests;") + lines.append("\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";") + lines.append("\t\t\t\tSWIFT_VERSION = 5.0;") + lines.append("\t\t\t\tTARGETED_DEVICE_FAMILY = 1;") + lines.append(f"\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/IdleTycoon.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/IdleTycoon\";") + lines.append("\t\t\t};") + lines.append(f"\t\t\tname = {name};") + lines.append("\t\t};") + + project_config(ids["debug_project"], "Debug") + project_config(ids["release_project"], "Release") + app_config(ids["debug_app"], "Debug") + app_config(ids["release_app"], "Release") + test_config(ids["debug_test"], "Debug") + test_config(ids["release_test"], "Release") + lines.append("/* End XCBuildConfiguration section */") + lines.append("") + lines.append("/* Begin XCConfigurationList section */") + lines.append( + f"\t\t{ids['project_config_list']} /* Build configuration list for PBXProject \"IdleTycoon\" */ = {{" + ) + lines.append("\t\t\tisa = XCConfigurationList;") + lines.append("\t\t\tbuildConfigurations = (") + lines.append(f"\t\t\t\t{ids['debug_project']} /* Debug */,") + lines.append(f"\t\t\t\t{ids['release_project']} /* Release */,") + lines.append("\t\t\t);") + lines.append("\t\t\tdefaultConfigurationIsVisible = 0;") + lines.append("\t\t\tdefaultConfigurationName = Release;") + lines.append("\t\t};") + lines.append( + f"\t\t{ids['app_config_list']} /* Build configuration list for PBXNativeTarget \"IdleTycoon\" */ = {{" + ) + lines.append("\t\t\tisa = XCConfigurationList;") + lines.append("\t\t\tbuildConfigurations = (") + lines.append(f"\t\t\t\t{ids['debug_app']} /* Debug */,") + lines.append(f"\t\t\t\t{ids['release_app']} /* Release */,") + lines.append("\t\t\t);") + lines.append("\t\t\tdefaultConfigurationIsVisible = 0;") + lines.append("\t\t\tdefaultConfigurationName = Release;") + lines.append("\t\t};") + lines.append( + f"\t\t{ids['test_config_list']} /* Build configuration list for PBXNativeTarget \"IdleTycoonTests\" */ = {{" + ) + lines.append("\t\t\tisa = XCConfigurationList;") + lines.append("\t\t\tbuildConfigurations = (") + lines.append(f"\t\t\t\t{ids['debug_test']} /* Debug */,") + lines.append(f"\t\t\t\t{ids['release_test']} /* Release */,") + lines.append("\t\t\t);") + lines.append("\t\t\tdefaultConfigurationIsVisible = 0;") + lines.append("\t\t\tdefaultConfigurationName = Release;") + lines.append("\t\t};") + lines.append("/* End XCConfigurationList section */") + lines.append("\t};") + lines.append(f"\trootObject = {ids['project']} /* Project object */;") + lines.append("}") + + PROJECT.mkdir(parents=True, exist_ok=True) + (PROJECT / "project.pbxproj").write_text("\n".join(lines) + "\n", encoding="utf-8") + + scheme_dir = PROJECT / "xcshareddata" / "xcschemes" + scheme_dir.mkdir(parents=True, exist_ok=True) + scheme = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + (scheme_dir / "IdleTycoon.xcscheme").write_text(scheme, encoding="utf-8") + print(f"Wrote {PROJECT}") + + +if __name__ == "__main__": + main() diff --git a/IdleTycoon/scripts/test_economy.py b/IdleTycoon/scripts/test_economy.py new file mode 100644 index 0000000..7420aec --- /dev/null +++ b/IdleTycoon/scripts/test_economy.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Mirror of IdleTycoon Economy math for environments without Xcode/Swift.""" + +from __future__ import annotations + +import math +import sys + + +MAX_OFFLINE = 8 * 60 * 60 +OFFLINE_EFFICIENCY = 0.5 + + +def cost_to_buy(base_cost: float, growth: float, owned: int, count: int = 1) -> float: + if count <= 0: + return 0.0 + start = growth**owned + if abs(growth - 1) < 1e-12: + return base_cost * start * count + return base_cost * start * (growth**count - 1) / (growth - 1) + + +def assert_close(actual: float, expected: float, tol: float = 1e-9) -> None: + if abs(actual - expected) > tol: + raise AssertionError(f"{actual} != {expected} (tol={tol})") + + +def test_geometric_buy_cost() -> None: + assert_close(cost_to_buy(100, 1.15, 0, 1), 100) + expected = 100 + 100 * 1.15 + 100 * 1.15 * 1.15 + assert_close(cost_to_buy(100, 1.15, 0, 3), expected) + assert_close(cost_to_buy(100, 1.15, 2, 1), 100 * (1.15**2)) + + +def test_passive_with_multiplier() -> None: + # 1 cart @ 0.1/s * multiplier 2 * 10s => 2.0 + income = 0.1 * 1 * 2 + coins = income * 10 + assert_close(coins, 2.0) + + +def test_offline_cap() -> None: + income = 0.1 + elapsed = 24 * 60 * 60 + capped = min(elapsed, MAX_OFFLINE) + earned = income * capped * OFFLINE_EFFICIENCY + assert_close(earned, 0.1 * MAX_OFFLINE * 0.5) + + +def test_buy_rejects_when_broke() -> None: + coins = 10 + cost = cost_to_buy(100, 1.15, 0, 1) + assert coins < cost + + +def test_currency_suffixes() -> None: + def fmt(value: float) -> str: + suffixes = ["", "K", "M", "B", "T", "Qa", "Qi", "Sx"] + sign = "-" if value < 0 else "" + n = abs(value) + idx = 0 + while n >= 1000 and idx < len(suffixes) - 1: + n /= 1000 + idx += 1 + if idx == 0: + if n >= 100: + return f"{sign}{int(round(n))}" + if n >= 10: + return f"{sign}{n:.1f}" + return f"{sign}{n:.2f}" + return f"{sign}{n:.2f}{suffixes[idx]}" + + assert fmt(12.34) == "12.3" + assert fmt(9.87) == "9.87" + assert fmt(1500) == "1.50K" + assert fmt(2_000_000) == "2.00M" + + +def main() -> int: + tests = [ + test_geometric_buy_cost, + test_passive_with_multiplier, + test_offline_cap, + test_buy_rejects_when_broke, + test_currency_suffixes, + ] + for test in tests: + test() + print(f"ok {test.__name__}") + print(f"\n{len(tests)} passed") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except AssertionError as exc: + print(f"FAIL: {exc}", file=sys.stderr) + raise SystemExit(1)