diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..2136008d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,204 @@ +# TravianBotSharp Copilot Instructions + +## Project Overview +TravianBotSharp (TBS) is a Windows-only bot for Travian that automates gameplay via Selenium browser automation. It uses a **handler-based architecture** with task scheduling, command orchestration, and HTML parsing. + +## Architecture + +### Core Pattern: Handlers + Tasks + Commands +The project uses **Immediate.Handlers** library with a custom constraint hierarchy: + +**Request Types** (all inherit from `IConstraint`): +- `ICommand`: Atomic actions, decorated with `[Handler]` attribute +- `ITask`: Scheduled automation workflows, also use `[Handler]` pattern +- Scoped by: `IAccountCommand`, `IVillageCommand`, `IAccountVillageCommand` + +**Command Structure** (see [LoginCommand.cs](MainCore/Commands/Features/LoginCommand.cs)): +```csharp +[Handler] +public static partial class LoginCommand { + public sealed record Command(AccountId AccountId) : IAccountCommand; + + private static async ValueTask HandleAsync( + Command command, + IChromeBrowser browser, // Injected dependencies + AppDbContext context, + CancellationToken cancellationToken) { /* ... */ } +} +``` + +**Task Hierarchy** (see [LoginTask.cs](MainCore/Tasks/LoginTask.cs)): +- `BaseTask` → `AccountTask` → Specific tasks (e.g., `LoginTask`) +- Tasks orchestrate multiple commands +- Handlers are auto-registered via `[Handler]` attribute + +### Behavior Pipeline (Middleware-like Decorators) +Applied in assembly attribute (see [AppMixins.cs](MainCore/AppMixins.cs#L7-L16)): +1. `AccountDataLoggingBehavior`: Adds account context to logs +2. `TaskNameLoggingBehavior`: Logs task execution +3. `CommandLoggingBehavior`: Logs command execution +4. `ErrorLoggingBehavior`: Logs errors +5. `AccountTaskBehavior`: Checks login state, updates account/village info +6. `VillageTaskBehavior`: Ensures village context + +Behaviors enforce pre-conditions before task/command execution (see [AccountTaskBehavior.cs](MainCore/Behaviors/AccountTaskBehavior.cs#L26-L50)). + +### Error Handling: FluentResults +- Returns `Result` or `Result` from handlers +- Custom error types: `Stop`, `Skip`, `Retry`, `Cancel`, `MissingBuilding`, `LackOfFreeCrop`, etc. +- `Stop`: Fatal error, halt bot +- `Skip`: Reschedule task for later +- `Retry`: Repeat immediately + +## Key Dependencies & Patterns + +| Component | Purpose | Key Detail | +|-----------|---------|-----------| +| **Selenium** | Browser automation | Wrapped by `IChromeBrowser` service | +| **HtmlAgilityPack** | HTML parsing | Used by `MainCore.Parsers` module | +| **EF Core + SQLite** | Data persistence | Configured in [AppMixins.cs#L36-L42](MainCore/AppMixins.cs#L36-L42) | +| **Specifications** (Ardalis) | Query builders | See [VillagesSpec.cs](MainCore/Specifications/VillagesSpec.cs) | +| **FluentValidation** | Request validation | Auto-registered in [AppMixins.cs#L50](MainCore/AppMixins.cs#L50) | +| **Serilog** | Structured logging | Account-enriched logs, daily rotation to `./logs/` | +| **ReactiveUI** | Reactive patterns | Used in UI layer | + +## Critical Conventions + +### Naming & Sealing (Enforced by ArchUnitNET tests) +- **Commands**: Must be `sealed record Command(...)` ending with "Command" suffix +- **Tasks**: Must be sealed `class Task : AccountTask/VillageTask`, naming flexible +- **Handlers**: Must be `private static async ValueTask HandleAsync(...)` +- **Specifications**: Inherit from `Specification` + +### Architecture Tests ([ArchitectureTest.cs](MainCore.Test/ArchitectureTest.cs)) +Enforces naming conventions via reflection. Before adding new commands/tasks: +- Commands must be sealed records +- Tasks must be sealed classes +- Handlers must be static methods named `HandleAsync` + +### Data Transfer +- **DTOs**: Light objects for client communication (see [AccountDetailDto.cs](MainCore/DTO/AccountDetailDto.cs)) +- **Entities**: Database models (see [MainCore/Entities/](MainCore/Entities/)) +- **Mapping**: Use Riok.Mapperly for DTO ↔ Entity conversion + +### Parsers: HTML Extraction +All Travian HTML parsing in [MainCore/Parsers/](MainCore/Parsers/). Examples: +- `LoginParser.IsIngamePage()`: Checks for server time element +- `LoginParser.GetUsernameInput()`: Finds login form +- Pattern: Static methods return `HtmlNode?`, use HtmlAgilityPack DOM traversal + +## Data Flow + +``` +UI Request → Task Scheduled + ↓ +Task.HandleAsync() executes + ↓ +Behaviors check pre-conditions (login, village context) + ↓ +Task orchestrates Commands + ↓ +Commands interact with: + - IChromeBrowser: Selenium-backed browser + - AppDbContext: SQLite access + - Parsers: Extract data from HTML + ↓ +Result (success/error) bubbles up + ↓ +Behaviors log and update state +``` + +## Development Workflows + +### Running the Application +- **Build**: `dotnet build` (Windows only via RuntimeIdentifier) +- **Debug**: F5 in Visual Studio (starts WPFUI project) +- **Test**: `dotnet test MainCore.Test` (runs ArchUnitNET architecture + unit tests) + +### Adding New Features + +**Step 1: Create Command** +```csharp +// MainCore/Commands/Features/MyFeature/MyCommand.cs +[Handler] +public static partial class MyCommand { + public sealed record Command(AccountId AccountId) : IAccountCommand; + + private static async ValueTask HandleAsync( + Command command, + IChromeBrowser browser, + AppDbContext context, + CancellationToken cancellationToken) { + // Implement using browser.Click, browser.Input, browser.GetElement + // Parse response with HtmlAgilityPack or Parsers + return Result.Ok(); // or Result.Fail(error) + } +} +``` + +**Step 2: Create Task** (if needed as automation workflow) +```csharp +// MainCore/Tasks/MyTask.cs +[Handler] +public static partial class MyTask { + public sealed class Task(AccountId accountId) : AccountTask(accountId) { + protected override string TaskName => "My Task"; + } + + private static async ValueTask HandleAsync( + Task task, + MyCommand.Handler myCommand, + IChromeBrowser browser, + CancellationToken cancellationToken) { + var result = await myCommand.HandleAsync(new(task.AccountId), cancellationToken); + if (result.IsFailed) return result; + return Result.Ok(); + } +} +``` + +**Step 3: Update Tests** +- Add validator tests for input validation +- Ensure command/task naming follows conventions (ArchUnitNET will verify) + +### Database Queries +Use Specifications pattern via [Ardalis.Specification](https://github.com/ardalis/Specification): +```csharp +var spec = new VillagesSpec(accountId); +var villages = await context.ApplySpecification(spec).ToListAsync(); +``` + +### Adding Parsers +- Static methods in `MainCore.Parsers.*` +- Use HtmlAgilityPack: `doc.GetElementbyId()`, `Descendants()`, class/attribute selectors +- Return `HtmlNode?` for optional elements +- Pattern: `GetXxx()` for element finders, `IsXxx()` for boolean checks + +## Common Gotchas + +1. **Account Context Missing**: Ensure `IDataService` is initialized with `AccountId` before creating `IChromeBrowser` (see [AppMixins.cs#L56-L61](MainCore/AppMixins.cs#L56-L61)) +2. **Login State**: `AccountTaskBehavior` auto-injects `LoginTask` if not in-game; check with `LoginParser.IsIngamePage()` +3. **Storage SQLite Connection**: Shared cache mode allows concurrent access; no multi-process restrictions +4. **Result Error Propagation**: Always use `if (result.IsFailed) return result;` pattern—don't ignore failures +5. **HTML Parsing Fragility**: Travian UI changes break parsers; add fallback selectors in parsers + +## Key Files Reference + +| File | Purpose | +|------|---------| +| [AppMixins.cs](MainCore/AppMixins.cs) | DI registration, Serilog setup, DbContext config | +| [MainCore.csproj](MainCore/MainCore.csproj) | Dependencies, build configuration | +| [Constraints/](MainCore/Constraints/) | Interfaces defining Request hierarchy | +| [Behaviors/](MainCore/Behaviors/) | Pre/post-execution logic for tasks/commands | +| [Services/](MainCore/Services/) | Chrome, Timer, Delay, Logger, Settings, Data services | +| [Parsers/](MainCore/Parsers/) | Travian HTML extraction logic | +| [Specifications/](MainCore/Specifications/) | EF query builders | +| [Errors/](MainCore/Errors/) | Custom error types for Result | + +## Testing Strategy +- **Unit Tests**: Validator tests, integration tests with fake DbContext ([FakeDbContextFactory.cs](MainCore.Test/FakeDbContextFactory.cs)) +- **Architecture Tests**: Enforce naming/sealing conventions via ArchUnitNET ([ArchitectureTest.cs](MainCore.Test/ArchitectureTest.cs)) +- **Integration**: Small, test-specific scenarios; full end-to-end testing via manual bot runs + +--- +Last updated: 2026-02-28 | Architecture: Handler Pattern + Task Orchestration + HTML Parsing diff --git a/MainCore.Test/Services/SleepCommandTests.cs b/MainCore.Test/Services/SleepCommandTests.cs new file mode 100644 index 00000000..006c5f86 --- /dev/null +++ b/MainCore.Test/Services/SleepCommandTests.cs @@ -0,0 +1,70 @@ +using MainCore.Commands.Features; +using MainCore.Enums; +using MainCore.Services; +using MainCore.Entities; +using Xunit; + +namespace MainCore.Test.Services +{ + public class SleepCommandTests + { + private class FakeSettings : ISettingService + { + private readonly Dictionary _settings = new(); + + public void Set(AccountSettingEnums setting, int value) => _settings[$"A_{setting}"] = value; + + public bool BooleanByName(AccountId accountId, AccountSettingEnums setting) => throw new NotImplementedException(); + public bool BooleanByName(VillageId villageId, VillageSettingEnums setting) => throw new NotImplementedException(); + public int ByName(AccountId accountId, AccountSettingEnums settingMin, AccountSettingEnums settingMax, int multiplier = 1) + { + return _settings.TryGetValue($"A_{settingMin}", out var v) ? v : 0; + } + public int ByName(AccountId accountId, AccountSettingEnums setting) + { + return _settings.TryGetValue($"A_{setting}", out var v) ? v : 0; + } + public Dictionary ByName(VillageId villageId, List settings) => throw new NotImplementedException(); + public int ByName(VillageId villageId, VillageSettingEnums setting) => throw new NotImplementedException(); + public int ByName(VillageId villageId, VillageSettingEnums settingMin, VillageSettingEnums settingMax, int multiplier = 1) => throw new NotImplementedException(); + } + + [Fact] + public void CalculateSleepDuration_DoesNotExceedNextStart() + { + var fake = new FakeSettings(); + var account = new AccountId(1); + + // choose a work start a couple minutes in the future + var now = DateTime.Now; + var future = now.AddMinutes(3); + fake.Set(AccountSettingEnums.WorkStartHour, future.Hour); + fake.Set(AccountSettingEnums.WorkStartMinute, future.Minute); + + // choose a very large sleep value so it would normally overshoot + fake.Set(AccountSettingEnums.SleepTimeMin, 1000); + fake.Set(AccountSettingEnums.SleepTimeMax, 1000); + + int result = SleepCommand.CalculateSleepDurationMinutes(fake, account); + // should be no more than about 3 minutes + Assert.InRange(result, 0, 5); + } + + [Fact] + public void CalculateSleepDuration_UsesRandomWithinBounds() + { + var fake = new FakeSettings(); + var account = new AccountId(1); + + // arbitrary work start far in future so it doesn't cap + fake.Set(AccountSettingEnums.WorkStartHour, 23); + fake.Set(AccountSettingEnums.WorkStartMinute, 59); + + fake.Set(AccountSettingEnums.SleepTimeMin, 5); + fake.Set(AccountSettingEnums.SleepTimeMax, 5); + + int result = SleepCommand.CalculateSleepDurationMinutes(fake, account); + Assert.Equal(5, result); + } + } +} \ No newline at end of file diff --git a/MainCore.Test/UI/Models/Input/AccountSettingInputTest.cs b/MainCore.Test/UI/Models/Input/AccountSettingInputTest.cs index ede3e3dc..36fa6d0f 100644 --- a/MainCore.Test/UI/Models/Input/AccountSettingInputTest.cs +++ b/MainCore.Test/UI/Models/Input/AccountSettingInputTest.cs @@ -1,5 +1,6 @@ using MainCore.Enums; using MainCore.UI.Models.Input; +using System.Reflection; namespace MainCore.Test.UI.Models.Input { @@ -14,9 +15,71 @@ public void Get_ReturnsDictionaryWithCorrectCount() // Act var result = accountSettingInput.Get(); - // Assert - var enumCount = Enum.GetValues(typeof(AccountSettingEnums)).Length; - Assert.Equal(enumCount, result.Count); + // Assert that every returned key corresponds to a valid enum value and + // that deprecated settings (SleepTimeMin/Max) are not exposed in the UI. + foreach (var key in result.Keys) + { + Assert.True(Enum.IsDefined(typeof(AccountSettingEnums), key)); + } + Assert.DoesNotContain(AccountSettingEnums.SleepTimeMin, result.Keys); + Assert.DoesNotContain(AccountSettingEnums.SleepTimeMax, result.Keys); + } + + [Fact] + public void MinutesSettings_ArePersistedThroughDictionary() + { + var input = new AccountSettingInput(); + input.WorkStartHour.Set(3); + input.WorkStartMinute.Set(15); + input.WorkEndHour.Set(20); + input.WorkEndMinute.Set(45); + input.RandomMinute.Set(12); + + var dict = input.Get(); + Assert.Equal(3, dict[AccountSettingEnums.WorkStartHour]); + Assert.Equal(15, dict[AccountSettingEnums.WorkStartMinute]); + Assert.Equal(20, dict[AccountSettingEnums.WorkEndHour]); + Assert.Equal(45, dict[AccountSettingEnums.WorkEndMinute]); + Assert.Equal(12, dict[AccountSettingEnums.SleepRandomMinute]); + + // feeding back through Set should keep values + var input2 = new AccountSettingInput(); + input2.Set(dict); + Assert.Equal(3, input2.WorkStartHour.Get()); + Assert.Equal(15, input2.WorkStartMinute.Get()); + Assert.Equal(20, input2.WorkEndHour.Get()); + Assert.Equal(45, input2.WorkEndMinute.Get()); + Assert.Equal(12, input2.RandomMinute.Get()); + } + + [Fact] + public async Task SaveAccountSettingCommand_UpsertsNewValues() + { + // arrange in-memory context with a single account + var factory = new FakeDbContextFactory(); + await using var context = factory.CreateDbContext(true); + var accountId = context.Accounts.Select(a => a.Id).First(); + + var settings = new Dictionary + { + { AccountSettingEnums.WorkStartHour, 8 }, + { AccountSettingEnums.WorkStartMinute, 30 } + }; + var command = new MainCore.Commands.UI.Misc.SaveAccountSettingCommand.Command(new(accountId), settings); + + // invoke private static handler via reflection + var method = typeof(MainCore.Commands.UI.Misc.SaveAccountSettingCommand) + .GetMethod("HandleAsync", BindingFlags.NonPublic | BindingFlags.Static)!; + var task = (ValueTask)method.Invoke(null, new object[] { command, context, null! })!; + await task; + + // assert rows exist with correct values + var row1 = context.AccountsSetting.FirstOrDefault(x => x.AccountId == accountId && x.Setting == AccountSettingEnums.WorkStartHour); + var row2 = context.AccountsSetting.FirstOrDefault(x => x.AccountId == accountId && x.Setting == AccountSettingEnums.WorkStartMinute); + Assert.NotNull(row1); + Assert.NotNull(row2); + Assert.Equal(8, row1.Value); + Assert.Equal(30, row2.Value); } } } \ No newline at end of file diff --git a/MainCore/AppMixins.cs b/MainCore/AppMixins.cs index 6a4bc3d5..360796ed 100644 --- a/MainCore/AppMixins.cs +++ b/MainCore/AppMixins.cs @@ -1,4 +1,5 @@ using MainCore.Behaviors; +using MainCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; @@ -52,6 +53,7 @@ public static IHostBuilder ConfigureServices(this IHostBuilder hostBuilder) => services.AddValidatorsFromAssembly(typeof(AppMixins).Assembly, ServiceLifetime.Singleton); services.AddMainCoreBehaviors(); services.AddMainCoreHandlers(); + services.AddSingleton(new CommandLoggingConfig()); services.AddScoped(sp => { diff --git a/MainCore/Behaviors/CommandLoggingBehavior.cs b/MainCore/Behaviors/CommandLoggingBehavior.cs index 3d1b3bcf..07512454 100644 --- a/MainCore/Behaviors/CommandLoggingBehavior.cs +++ b/MainCore/Behaviors/CommandLoggingBehavior.cs @@ -1,29 +1,28 @@ -namespace MainCore.Behaviors +using MainCore.Infrastructure; + +namespace MainCore.Behaviors { public sealed class CommandLoggingBehavior : Behavior where TRequest : ICommand { private readonly ILogger _logger; + private readonly CommandLoggingConfig _config; - public CommandLoggingBehavior(ILogger logger) + public CommandLoggingBehavior(ILogger logger, CommandLoggingConfig config) { _logger = logger; + _config = config; } - private static readonly string[] ExcludedCommandNames = new[] - { - "Update", - "Delay", - "NextExecute" - }; - public override async ValueTask HandleAsync(TRequest request, CancellationToken cancellationToken) { - var name = request.GetType().FullName; - if (!string.IsNullOrEmpty(name) && !ExcludedCommandNames.Any(name.Contains)) + var commandFullName = request.GetType().FullName; + if (!string.IsNullOrEmpty(commandFullName) && _config.ShouldLog(commandFullName)) { - name = name + var logLevel = _config.GetLogLevel(commandFullName); + + var simpleName = commandFullName .Replace("MainCore.", "") .Replace("+Command", ""); @@ -38,11 +37,11 @@ public override async ValueTask HandleAsync(TRequest request, Cancell if (dict.Count == 0) { - _logger.Information("Execute {Name}", name); + _logger.Write(logLevel, "Execute {Name}", simpleName); } else { - _logger.Information("Execute {Name} {@Dict}", name, dict); + _logger.Write(logLevel, "Execute {Name} {@Dict}", simpleName, dict); } } diff --git a/MainCore/Commands/Features/SleepCommand.cs b/MainCore/Commands/Features/SleepCommand.cs index 6a38d80b..5b4f902c 100644 --- a/MainCore/Commands/Features/SleepCommand.cs +++ b/MainCore/Commands/Features/SleepCommand.cs @@ -5,6 +5,27 @@ public static partial class SleepCommand { public sealed record Command(AccountId AccountId) : IAccountCommand; + public static int CalculateSleepDurationMinutes(ISettingService settingService, AccountId accountId) + { + // pick random number between configured min/max + var sleepTimeMinutes = settingService.ByName(accountId, AccountSettingEnums.SleepTimeMin, AccountSettingEnums.SleepTimeMax); + + // determine next day start, respecting configured work window + var workStartHour = settingService.ByName(accountId, AccountSettingEnums.WorkStartHour); + if (workStartHour < 0 || workStartHour > 23) workStartHour = 6; + var workStartMinute = settingService.ByName(accountId, AccountSettingEnums.WorkStartMinute); + if (workStartMinute < 0 || workStartMinute > 59) workStartMinute = 0; + + var now = DateTime.Now; + var startToday = now.Date.AddHours(workStartHour).AddMinutes(workStartMinute); + DateTime nextStart = now < startToday ? startToday : startToday.AddDays(1); + + var maxAllowed = (int)Math.Ceiling((nextStart - now).TotalMinutes); + if (sleepTimeMinutes > maxAllowed) sleepTimeMinutes = maxAllowed; + + return sleepTimeMinutes; + } + private static async ValueTask HandleAsync( Command command, IChromeBrowser browser, @@ -14,7 +35,7 @@ private static async ValueTask HandleAsync( { await browser.Close(); - var sleepTimeMinutes = settingService.ByName(command.AccountId, AccountSettingEnums.SleepTimeMin, AccountSettingEnums.SleepTimeMax); + var sleepTimeMinutes = CalculateSleepDurationMinutes(settingService, command.AccountId); var sleepEnd = DateTime.Now.AddMinutes(sleepTimeMinutes); int lastMinute = 0; while (true) diff --git a/MainCore/Commands/Navigate/ToBuildingByLocationCommand.cs b/MainCore/Commands/Navigate/ToBuildingByLocationCommand.cs index 53933694..7f752b1b 100644 --- a/MainCore/Commands/Navigate/ToBuildingByLocationCommand.cs +++ b/MainCore/Commands/Navigate/ToBuildingByLocationCommand.cs @@ -21,6 +21,12 @@ public static async ValueTask ToBuilding( IChromeBrowser browser, CancellationToken cancellationToken) { + // before trying to locate a building link, make sure the page is stable + // (logo rendered). using an empty fragment avoids hanging if we're already + // on a non-dorf page such as a build screen. + var waitResult = await browser.WaitPageChanged("", cancellationToken); + if (waitResult.IsFailed) return waitResult; + var (_, isFailed, element, errors) = await browser.GetElement(doc => GetBuilding(doc, location), cancellationToken); if (isFailed) return Result.Fail(errors).WithError($"Failed to find [building at #{location}]"); diff --git a/MainCore/Commands/Navigate/ToDorfCommand.cs b/MainCore/Commands/Navigate/ToDorfCommand.cs index cde5acf4..c951ffcf 100644 --- a/MainCore/Commands/Navigate/ToDorfCommand.cs +++ b/MainCore/Commands/Navigate/ToDorfCommand.cs @@ -26,6 +26,13 @@ CancellationToken cancellationToken return Result.Ok(); } + // ensure the *current* page is fully rendered before looking for the button. + // `WaitPageChanged` takes a URL fragment but we only care about the logo being + // visible; passing an empty string makes the URL check no‑op and still waits + // for the logo, avoiding deadlocks when we're on a non-dorf page. + var waitResult = await browser.WaitPageChanged("", cancellationToken); + if (waitResult.IsFailed) return waitResult; + var (_, isFailed, element, errors) = await browser.GetElement(doc => NavigationBarParser.GetDorfButton(doc, dorf), cancellationToken); if (isFailed) return Result.Fail(errors); diff --git a/MainCore/Commands/NextExecute/NextExecuteSleepTaskCommand.cs b/MainCore/Commands/NextExecute/NextExecuteSleepTaskCommand.cs index a89d042f..3879c86a 100644 --- a/MainCore/Commands/NextExecute/NextExecuteSleepTaskCommand.cs +++ b/MainCore/Commands/NextExecute/NextExecuteSleepTaskCommand.cs @@ -11,12 +11,59 @@ ISettingService settingService ) { await Task.CompletedTask; - var workTime = settingService.ByName( + + // retrieve static work hours (defaults: 6am to 10pm if not set) + var workStartHour = settingService.ByName( command.Task.AccountId, - AccountSettingEnums.WorkTimeMin, - AccountSettingEnums.WorkTimeMax, - 60); - command.Task.ExecuteAt = DateTime.Now.AddSeconds(workTime); + AccountSettingEnums.WorkStartHour); + if (workStartHour < 0 || workStartHour > 23) workStartHour = 6; + + var workStartMinute = settingService.ByName( + command.Task.AccountId, + AccountSettingEnums.WorkStartMinute); + if (workStartMinute < 0 || workStartMinute > 59) workStartMinute = 0; + + var workEndHour = settingService.ByName( + command.Task.AccountId, + AccountSettingEnums.WorkEndHour); + if (workEndHour < 0 || workEndHour > 23) workEndHour = 22; + + var workEndMinute = settingService.ByName( + command.Task.AccountId, + AccountSettingEnums.WorkEndMinute); + if (workEndMinute < 0 || workEndMinute > 59) workEndMinute = 0; + + // randomness range from 0 (no offset) up to configured max + var random = new Random(); + var maxOffset = settingService.ByName( + command.Task.AccountId, + AccountSettingEnums.SleepRandomMinute); + if (maxOffset < 0) maxOffset = 0; + var randomMinute = maxOffset == 0 ? 0 : random.Next(0, maxOffset); + + var now = DateTime.Now; + DateTime nextSleepTime; + + var startToday = now.Date.AddHours(workStartHour).AddMinutes(workStartMinute); + var endToday = now.Date.AddHours(workEndHour).AddMinutes(workEndMinute); + + if (now < startToday) + { + // before work start, wake exactly when work starts (jitter not applied) + nextSleepTime = startToday; + } + else if (now >= endToday) + { + // after work end, wake at next day's start + nextSleepTime = startToday.AddDays(1); + } + else + { + // during work hours, sleep until end of work today (+optional jitter) + nextSleepTime = endToday.AddMinutes(randomMinute); + } + + command.Task.ExecuteAt = nextSleepTime; } } } \ No newline at end of file diff --git a/MainCore/Commands/UI/Misc/SaveAccountSettingCommand.cs b/MainCore/Commands/UI/Misc/SaveAccountSettingCommand.cs index 01043e40..19039ddf 100644 --- a/MainCore/Commands/UI/Misc/SaveAccountSettingCommand.cs +++ b/MainCore/Commands/UI/Misc/SaveAccountSettingCommand.cs @@ -17,11 +17,18 @@ ITaskManager taskManager foreach (var setting in settings) { - context.AccountsSetting - .Where(x => x.AccountId == accountId.Value) - .Where(x => x.Setting == setting.Key) - .ExecuteUpdate(x => x.SetProperty(x => x.Value, setting.Value)); + var entity = context.AccountsSetting + .FirstOrDefault(x => x.AccountId == accountId.Value && x.Setting == setting.Key); + if (entity == null) + { + context.AccountsSetting.Add(new() { AccountId = accountId.Value, Setting = setting.Key, Value = setting.Value }); + } + else + { + entity.Value = setting.Value; + } } + await context.SaveChangesAsync(); if (settings.ContainsKey(AccountSettingEnums.EnableAutoLoadVillageBuilding)) { diff --git a/MainCore/Enums/AccountSettingEnums.cs b/MainCore/Enums/AccountSettingEnums.cs index e48ef55c..b8449da1 100644 --- a/MainCore/Enums/AccountSettingEnums.cs +++ b/MainCore/Enums/AccountSettingEnums.cs @@ -11,11 +11,14 @@ public enum AccountSettingEnums FarmIntervalMin, FarmIntervalMax, Tribe, - WorkTimeMin, - WorkTimeMax, SleepTimeMin, SleepTimeMax, HeadlessChrome, EnableAutoStartAdventure, + WorkStartHour, + WorkEndHour, + WorkStartMinute, + WorkEndMinute, + SleepRandomMinute, } } \ No newline at end of file diff --git a/MainCore/Infrastructure/CommandLoggingConfig.cs b/MainCore/Infrastructure/CommandLoggingConfig.cs new file mode 100644 index 00000000..8f5f96dc --- /dev/null +++ b/MainCore/Infrastructure/CommandLoggingConfig.cs @@ -0,0 +1,78 @@ +using Serilog.Events; + +namespace MainCore.Infrastructure +{ + /// + /// Configuration for command logging behavior. + /// Controls which commands are logged and at which log level. + /// + public sealed class CommandLoggingConfig + { + /// + /// Commands excluded entirely from logging (e.g., "Update", "Delay"). + /// + public string[] ExcludedCommands { get; set; } = new[] + { + "Update", + "Delay", + "NextExecute" + }; + + /// + /// Commands logged at Debug level instead of Information. + /// Empty by default (logs everything at Information level). + /// Add command name patterns here to reduce noise. + /// Examples: "ToBuildingByType", "SwitchVillage", "Navigate" + /// + public string[] DebugLevelCommands { get; set; } = new[] + { + "ToBuildingByType", + "SwitchVillage", + "Navigate", + "ToNpcResourcePage", + "GetTrainTroopBuilding", + "GetBuildPlanCommand" + }; + + /// + /// Default log level when no specific rule applies. + /// + public LogEventLevel DefaultLevel { get; set; } = LogEventLevel.Information; + + /// + /// Enable/disable command logging entirely. + /// + public bool Enabled { get; set; } = true; + + /// + /// Determines the log level for a given command name. + /// + public LogEventLevel GetLogLevel(string commandFullName) + { + if (!Enabled) return LogEventLevel.Debug; // Will be filtered out by Serilog + + var simpleName = commandFullName + .Replace("MainCore.", "") + .Replace("+Command", ""); + + if (DebugLevelCommands.Any(pattern => simpleName.Contains(pattern))) + return LogEventLevel.Debug; + + return DefaultLevel; + } + + /// + /// Determines if a command should be logged at all. + /// + public bool ShouldLog(string commandFullName) + { + if (!Enabled) return false; + + var simpleName = commandFullName + .Replace("MainCore.", "") + .Replace("+Command", ""); + + return !ExcludedCommands.Any(pattern => simpleName.Contains(pattern)); + } + } +} diff --git a/MainCore/Infrasturecture/ExtensionFiles/tampermonkey_stable.crx b/MainCore/Infrasturecture/ExtensionFiles/tampermonkey_stable.crx new file mode 100644 index 00000000..3c5143f6 Binary files /dev/null and b/MainCore/Infrasturecture/ExtensionFiles/tampermonkey_stable.crx differ diff --git a/MainCore/Infrasturecture/Persistence/AppDbContext.cs b/MainCore/Infrasturecture/Persistence/AppDbContext.cs index fac461af..e83c7613 100644 --- a/MainCore/Infrasturecture/Persistence/AppDbContext.cs +++ b/MainCore/Infrasturecture/Persistence/AppDbContext.cs @@ -43,10 +43,13 @@ public AppDbContext(DbContextOptions options) : base(options) {AccountSettingEnums.Tribe, 0 }, {AccountSettingEnums.SleepTimeMin, 480 }, {AccountSettingEnums.SleepTimeMax, 600 }, - {AccountSettingEnums.WorkTimeMin, 600 }, - {AccountSettingEnums.WorkTimeMax, 720 }, {AccountSettingEnums.HeadlessChrome, 0 }, {AccountSettingEnums.EnableAutoStartAdventure, 0 }, + {AccountSettingEnums.WorkStartHour, 6 }, + {AccountSettingEnums.WorkStartMinute, 0 }, + {AccountSettingEnums.WorkEndHour, 22 }, + {AccountSettingEnums.WorkEndMinute, 0 }, + {AccountSettingEnums.SleepRandomMinute, 60 }, }.ToImmutableDictionary(); private List GetMissingAccountSettings() diff --git a/MainCore/MainCore.csproj b/MainCore/MainCore.csproj index dfb0489b..0feaddbe 100644 --- a/MainCore/MainCore.csproj +++ b/MainCore/MainCore.csproj @@ -8,7 +8,7 @@ true true - 1975.4.30 + 2603.01.4.0 False @@ -24,6 +24,12 @@ --> + + + + + + diff --git a/MainCore/Services/ChromeBrowser.cs b/MainCore/Services/ChromeBrowser.cs index af32f14f..e9562756 100644 --- a/MainCore/Services/ChromeBrowser.cs +++ b/MainCore/Services/ChromeBrowser.cs @@ -251,8 +251,18 @@ public async Task WaitPageChanged(string url, CancellationToken cancella result = await Wait(driver => { - var logo = driver.FindElements(By.Id("logo")); - return logo.Count > 0 && logo[0].Displayed && logo[0].Enabled; + var logos = driver.FindElements(By.Id("logo")); + if (logos.Count == 0) return false; + try + { + var logo = logos[0]; + return logo.Displayed && logo.Enabled; + } + catch (OpenQA.Selenium.StaleElementReferenceException) + { + // element went stale between find and property access; try again + return false; + } }, cancellationToken); if (result.IsFailed) return result.WithError("Failed to wait for logo to be displayed"); diff --git a/MainCore/Services/RxQueue.cs b/MainCore/Services/RxQueue.cs index b55841d0..99a75a1d 100644 --- a/MainCore/Services/RxQueue.cs +++ b/MainCore/Services/RxQueue.cs @@ -39,14 +39,54 @@ private void AccountInitHandler(AccountInit notification) taskManager.Add(new LoginTask.Task(accountId), first: true); - var workTime = context.ByName(accountId, AccountSettingEnums.WorkTimeMin, AccountSettingEnums.WorkTimeMax); + var workStartHour = context.ByName(accountId, AccountSettingEnums.WorkStartHour); + if (workStartHour < 0 || workStartHour > 23) workStartHour = 6; + var workStartMinute = context.ByName(accountId, AccountSettingEnums.WorkStartMinute); + if (workStartMinute < 0 || workStartMinute > 59) workStartMinute = 0; + var workEndHour = context.ByName(accountId, AccountSettingEnums.WorkEndHour); + if (workEndHour < 0 || workEndHour > 23) workEndHour = 22; + var workEndMinute = context.ByName(accountId, AccountSettingEnums.WorkEndMinute); + if (workEndMinute < 0 || workEndMinute > 59) workEndMinute = 0; + var maxOffset = context.ByName(accountId, AccountSettingEnums.SleepRandomMinute); + if (maxOffset < 0) maxOffset = 0; + var random = new Random(); + var randomMinute = maxOffset == 0 ? 0 : random.Next(0, maxOffset); + + var now = DateTime.Now; + DateTime sleepExecuteAt; + + var startToday = now.Date.AddHours(workStartHour).AddMinutes(workStartMinute); + var endToday = now.Date.AddHours(workEndHour).AddMinutes(workEndMinute); + + // Detect overnight windows (e.g., 7:15 AM to 2:30 AM next day) + bool windowCrossesMidnight = endToday < startToday; + bool outsideWindow = windowCrossesMidnight + ? (now >= endToday && now < startToday) // Outside if between end and start + : (now < startToday || now >= endToday); // Outside if before start or at/after end + + DateTime nextStart = now < startToday ? startToday : startToday.AddDays(1); + // helper used below when scheduling regular tasks + DateTime WhenAllowed(DateTime original) => outsideWindow ? nextStart : original; + + if (outsideWindow) + { + // We're outside the window; schedule sleep for next start time + sleepExecuteAt = nextStart; + } + else + { + // We're inside the window; schedule sleep for end of window + sleepExecuteAt = endToday.AddMinutes(randomMinute); + } + var sleepTask = new SleepTask.Task(accountId); - sleepTask.ExecuteAt = DateTime.Now.AddMinutes(workTime); + sleepTask.ExecuteAt = sleepExecuteAt; taskManager.AddOrUpdate(sleepTask); var startAdventureTask = new StartAdventureTask.Task(accountId); if (startAdventureTask.CanStart(context) && !taskManager.IsExist(accountId)) { + startAdventureTask.ExecuteAt = WhenAllowed(now); taskManager.Add(startAdventureTask); } var villagesSpec = new VillagesSpec(accountId); @@ -58,11 +98,13 @@ private void AccountInitHandler(AccountInit notification) var updateVillageTask = new UpdateVillageTask.Task(accountId, village); if (updateVillageTask.CanStart(context) && !taskManager.IsExist(accountId, village)) { + updateVillageTask.ExecuteAt = WhenAllowed(now); taskManager.Add(updateVillageTask); } var trainTroopTask = new TrainTroopTask.Task(accountId, village); if (trainTroopTask.CanStart(context) && !taskManager.IsExist(accountId, village)) { + trainTroopTask.ExecuteAt = WhenAllowed(now); taskManager.Add(trainTroopTask); } } @@ -75,6 +117,7 @@ private void AccountInitHandler(AccountInit notification) var upgradeBuildingTask = new UpgradeBuildingTask.Task(accountId, village); if (!taskManager.IsExist(accountId, village)) { + upgradeBuildingTask.ExecuteAt = WhenAllowed(now); taskManager.Add(upgradeBuildingTask); } } diff --git a/MainCore/Services/TimerManager.cs b/MainCore/Services/TimerManager.cs index d70a4b33..42410d18 100644 --- a/MainCore/Services/TimerManager.cs +++ b/MainCore/Services/TimerManager.cs @@ -2,6 +2,8 @@ using Polly; using Polly.Retry; using Timer = System.Timers.Timer; +using MainCore.Tasks; +using MainCore.Enums; namespace MainCore.Services { @@ -77,8 +79,47 @@ public async Task Execute(AccountId accountId) if (tasks.Count == 0) return; var task = tasks[0]; + // if it's not yet time, nothing to do if (task.ExecuteAt > DateTime.Now) return; + // enforce work–window: if we're outside the configured hours and this + // isn't the sleep task, bump its schedule to the next start time and + // leave everything else alone. the queue will be reordered and the + // loop will check again later, letting the SleepTask dictate when the + // bot actually wakes up. + var now = DateTime.Now; + using var scope = _serviceScopeFactory.CreateScope(accountId); + var db = scope.ServiceProvider.GetRequiredService(); + + var workStartHour = db.ByName(accountId, AccountSettingEnums.WorkStartHour); + if (workStartHour < 0 || workStartHour > 23) workStartHour = 6; + var workStartMinute = db.ByName(accountId, AccountSettingEnums.WorkStartMinute); + if (workStartMinute < 0 || workStartMinute > 59) workStartMinute = 0; + var workEndHour = db.ByName(accountId, AccountSettingEnums.WorkEndHour); + if (workEndHour < 0 || workEndHour > 23) workEndHour = 22; + var workEndMinute = db.ByName(accountId, AccountSettingEnums.WorkEndMinute); + if (workEndMinute < 0 || workEndMinute > 59) workEndMinute = 0; + + var startToday = now.Date.AddHours(workStartHour).AddMinutes(workStartMinute); + var endToday = now.Date.AddHours(workEndHour).AddMinutes(workEndMinute); + + // Detect overnight windows (e.g., 7:15 AM to 2:30 AM next day) + bool windowCrossesMidnight = endToday < startToday; + bool outsideWindow = windowCrossesMidnight + ? (now >= endToday && now < startToday) // Outside if between end and start + : (now < startToday || now >= endToday); // Outside if before start or at/after end + + DateTime nextStart = now < startToday ? startToday : startToday.AddDays(1); + + if (outsideWindow && + task.GetType() != typeof(SleepTask.Task) && + task.GetType() != typeof(LoginTask.Task)) + { + task.ExecuteAt = nextStart; + _taskManager.ReOrder(accountId); + return; + } + taskQueue.IsExecuting = true; var cts = new CancellationTokenSource(); taskQueue.CancellationTokenSource = cts; @@ -88,7 +129,7 @@ public async Task Execute(AccountId accountId) var cacheExecuteTime = task.ExecuteAt; - using var scope = _serviceScopeFactory.CreateScope(accountId); + // scope variable already declared above, reuse for execution logic ///===========================================================/// var browser = scope.ServiceProvider.GetRequiredService(); @@ -117,6 +158,32 @@ public async Task Execute(AccountId accountId) cts.Dispose(); taskQueue.CancellationTokenSource = null; + async Task RestartAccountAsync(string reason) + { + // take a screenshot for diagnostics + var filename = await browser.Screenshot(); + logger.Information("Screenshot saved as {FileName}", filename); + logger.Warning("{Reason}", reason); + + // detect whether StartFarmListTask was enabled for this account so we can + // re-enable it after the restart. We check before clearing the queue. + var hadStartFarm = _taskManager.IsExist(accountId); + + // replicate the UI Restart() logic: pause current task, clear queue, fire AccountInit + _taskManager.SetStatus(accountId, StatusEnums.Starting); + await Task.Delay(300); + _taskManager.Clear(accountId); + _rxQueue.Enqueue(new AccountInit(accountId)); + + // restore StartFarmListTask if it existed before restart + if (hadStartFarm) + { + _taskManager.AddOrUpdate(new(accountId)); + } + + _taskManager.SetStatus(accountId, StatusEnums.Online); + } + if (poliResult.Exception is not null) { var ex = poliResult.Exception; @@ -127,13 +194,10 @@ public async Task Execute(AccountId accountId) } else { - var filename = await browser.Screenshot(); - logger.Information("Screenshot saved as {FileName}", filename); - logger.Warning("There is something wrong. Bot is pausing. Last exception is"); + logger.Warning("There is something wrong. Last exception is"); logger.Error(ex, "{Message}", ex.Message); + await RestartAccountAsync("Restarting after exception"); } - - _taskManager.SetStatus(accountId, StatusEnums.Paused); } if (poliResult.Result is not null) @@ -148,11 +212,12 @@ public async Task Execute(AccountId accountId) logger.Warning("{Message}", message); } - if (result.HasError() || result.HasError()) + if (result.HasError() || result.HasError() || result.HasError()) { - var filename = await browser.Screenshot(); - logger.Information(messageTemplate: "Screenshot saved as {FileName}", filename); - _taskManager.SetStatus(accountId, StatusEnums.Paused); + // fatal-typed errors; instead of pausing, try to recover by + // forcing a re-login and keeping the account online. + logger.Warning("Fatal error detected ({Error}). restarting account", message); + await RestartAccountAsync("Restarting after fatal error"); } else if (result.HasError()) { diff --git a/MainCore/Tasks/SleepTask.cs b/MainCore/Tasks/SleepTask.cs index ba043f43..3e3a50d3 100644 --- a/MainCore/Tasks/SleepTask.cs +++ b/MainCore/Tasks/SleepTask.cs @@ -21,6 +21,7 @@ private static async ValueTask HandleAsync( SleepCommand.Handler sleepCommand, GetValidAccessCommand.Handler getAccessQuery, OpenBrowserCommand.Handler openBrowserCommand, + ITaskManager taskManager, NextExecuteSleepTaskCommand.Handler nextExecuteSleepTaskCommand, CancellationToken cancellationToken) { @@ -30,6 +31,10 @@ private static async ValueTask HandleAsync( if (isFailed) return Result.Fail(errors); await openBrowserCommand.HandleAsync(new(task.AccountId, access), cancellationToken); + + // ensure a fresh login task fires before any user activities + taskManager.Add(new LoginTask.Task(task.AccountId), first: true); + await nextExecuteSleepTaskCommand.HandleAsync(new(task), cancellationToken); return Result.Ok(); } diff --git a/MainCore/UI/Models/Input/AccountSettingInput.cs b/MainCore/UI/Models/Input/AccountSettingInput.cs index ecac078e..b5c58f41 100644 --- a/MainCore/UI/Models/Input/AccountSettingInput.cs +++ b/MainCore/UI/Models/Input/AccountSettingInput.cs @@ -5,13 +5,25 @@ namespace MainCore.UI.Models.Input { public partial class AccountSettingInput : ViewModelBase { + public AccountSettingInput() + { + WorkStartHour.Set(6); + WorkStartMinute.Set(0); + WorkEndHour.Set(22); + WorkEndMinute.Set(0); + RandomMinute.Set(60); + } + public void Set(Dictionary settings) { Tribe.Set((TribeEnums)settings.GetValueOrDefault(AccountSettingEnums.Tribe)); ClickDelay.Set(settings.GetValueOrDefault(AccountSettingEnums.ClickDelayMin), settings.GetValueOrDefault(AccountSettingEnums.ClickDelayMax)); TaskDelay.Set(settings.GetValueOrDefault(AccountSettingEnums.TaskDelayMin), settings.GetValueOrDefault(AccountSettingEnums.TaskDelayMax)); - WorkTime.Set(settings.GetValueOrDefault(AccountSettingEnums.WorkTimeMin), settings.GetValueOrDefault(AccountSettingEnums.WorkTimeMax)); - SleepTime.Set(settings.GetValueOrDefault(AccountSettingEnums.SleepTimeMin), settings.GetValueOrDefault(AccountSettingEnums.SleepTimeMax)); + WorkStartHour.Set(settings.GetValueOrDefault(AccountSettingEnums.WorkStartHour, 6)); + WorkStartMinute.Set(settings.GetValueOrDefault(AccountSettingEnums.WorkStartMinute, 0)); + WorkEndHour.Set(settings.GetValueOrDefault(AccountSettingEnums.WorkEndHour, 22)); + WorkEndMinute.Set(settings.GetValueOrDefault(AccountSettingEnums.WorkEndMinute, 0)); + RandomMinute.Set(settings.GetValueOrDefault(AccountSettingEnums.SleepRandomMinute, 60)); EnableAutoLoadVillage = settings.GetValueOrDefault(AccountSettingEnums.EnableAutoLoadVillageBuilding) == 1; HeadlessChrome = settings.GetValueOrDefault(AccountSettingEnums.HeadlessChrome) == 1; EnableAutoStartAdventure = settings.GetValueOrDefault(AccountSettingEnums.EnableAutoStartAdventure) == 1; @@ -25,8 +37,8 @@ public Dictionary Get() var (clickDelayMin, clickDelayMax) = ClickDelay.Get(); var (taskDelayMin, taskDelayMax) = TaskDelay.Get(); var isAutoLoadVillage = EnableAutoLoadVillage ? 1 : 0; - var (workTimeMin, workTimeMax) = WorkTime.Get(); - var (sleepTimeMin, sleepTimeMax) = SleepTime.Get(); + var workStartHour = WorkStartHour.Get(); + var workEndHour = WorkEndHour.Get(); var headlessChrome = HeadlessChrome ? 1 : 0; var autoStartAdventure = EnableAutoStartAdventure ? 1 : 0; @@ -46,10 +58,11 @@ public Dictionary Get() { AccountSettingEnums.UseStartAllButton, useStartAllButton }, { AccountSettingEnums.Tribe, tribe }, - { AccountSettingEnums.WorkTimeMax, workTimeMax }, - { AccountSettingEnums.WorkTimeMin, workTimeMin }, - { AccountSettingEnums.SleepTimeMax, sleepTimeMax }, - { AccountSettingEnums.SleepTimeMin, sleepTimeMin }, + { AccountSettingEnums.WorkStartHour, workStartHour }, + { AccountSettingEnums.WorkStartMinute, WorkStartMinute.Get() }, + { AccountSettingEnums.WorkEndHour, workEndHour }, + { AccountSettingEnums.WorkEndMinute, WorkEndMinute.Get() }, + { AccountSettingEnums.SleepRandomMinute, RandomMinute.Get() }, { AccountSettingEnums.HeadlessChrome, headlessChrome }, { AccountSettingEnums.EnableAutoStartAdventure, autoStartAdventure }, @@ -61,8 +74,11 @@ public Dictionary Get() public RangeInputViewModel ClickDelay { get; } = new(); public RangeInputViewModel TaskDelay { get; } = new(); - public RangeInputViewModel WorkTime { get; } = new(); - public RangeInputViewModel SleepTime { get; } = new(); + public HourInputViewModel WorkStartHour { get; } = new(); + public MinuteInputViewModel WorkStartMinute { get; } = new(); + public HourInputViewModel WorkEndHour { get; } = new(); + public MinuteInputViewModel WorkEndMinute { get; } = new(); + public MinuteInputViewModel RandomMinute { get; } = new(); public RangeInputViewModel FarmInterval { get; } = new(); [Reactive] diff --git a/MainCore/UI/Models/Validators/AccountSettingInputValidator.cs b/MainCore/UI/Models/Validators/AccountSettingInputValidator.cs index 525bd69b..225514cd 100644 --- a/MainCore/UI/Models/Validators/AccountSettingInputValidator.cs +++ b/MainCore/UI/Models/Validators/AccountSettingInputValidator.cs @@ -28,6 +28,16 @@ public AccountSettingInputValidator() RuleFor(x => x.FarmInterval.Min) .GreaterThanOrEqualTo(0) .WithMessage("Minimum farm interval ({PropertyValue}) should be positive number"); + + RuleFor(x => x.WorkStartMinute.Minute) + .InclusiveBetween(0, 59) + .WithMessage("Start minute must be between 0 and 59"); + RuleFor(x => x.WorkEndMinute.Minute) + .InclusiveBetween(0, 59) + .WithMessage("End minute must be between 0 and 59"); + RuleFor(x => x.RandomMinute.Minute) + .GreaterThanOrEqualTo(0) + .WithMessage("Random offset must be non‑negative"); } } } \ No newline at end of file diff --git a/MainCore/UI/ViewModels/Tabs/DebugViewModel.cs b/MainCore/UI/ViewModels/Tabs/DebugViewModel.cs index 4fb2e85b..a42e4e4c 100644 --- a/MainCore/UI/ViewModels/Tabs/DebugViewModel.cs +++ b/MainCore/UI/ViewModels/Tabs/DebugViewModel.cs @@ -144,7 +144,9 @@ private string LoadLog(AccountId accountId) private string ReloadLog() { using var sw = new StringWriter(new StringBuilder()); - foreach (var log in _logEvents) + // iterate over a snapshot to avoid concurrent modification when events + // are added from another thread via LogEmitted command + foreach (var log in _logEvents.ToList()) { _template.Format(log, sw); } diff --git a/MainCore/UI/ViewModels/UserControls/HourInputViewModel.cs b/MainCore/UI/ViewModels/UserControls/HourInputViewModel.cs new file mode 100644 index 00000000..502eeb36 --- /dev/null +++ b/MainCore/UI/ViewModels/UserControls/HourInputViewModel.cs @@ -0,0 +1,39 @@ +using MainCore.UI.ViewModels.Abstract; + +namespace MainCore.UI.ViewModels.UserControls +{ + public partial class HourInputViewModel : ViewModelBase + { + public HourInputViewModel() + { + IncreaseHourCommand = ReactiveCommand.Create(IncreaseHour); + DecreaseHourCommand = ReactiveCommand.Create(DecreaseHour); + } + + public void Set(int hour) + { + Hour = Math.Clamp(hour, 0, 23); + } + + public int Get() + { + return Hour; + } + + private void IncreaseHour() + { + Hour = Hour >= 23 ? 0 : Hour + 1; + } + + private void DecreaseHour() + { + Hour = Hour <= 0 ? 23 : Hour - 1; + } + + public ReactiveCommand IncreaseHourCommand { get; } + public ReactiveCommand DecreaseHourCommand { get; } + + [Reactive] + private int _hour = 6; + } +} diff --git a/MainCore/UI/ViewModels/UserControls/MinuteInputViewModel.cs b/MainCore/UI/ViewModels/UserControls/MinuteInputViewModel.cs new file mode 100644 index 00000000..7b3256e8 --- /dev/null +++ b/MainCore/UI/ViewModels/UserControls/MinuteInputViewModel.cs @@ -0,0 +1,39 @@ +using MainCore.UI.ViewModels.Abstract; + +namespace MainCore.UI.ViewModels.UserControls +{ + public partial class MinuteInputViewModel : ViewModelBase + { + public MinuteInputViewModel() + { + IncreaseMinuteCommand = ReactiveCommand.Create(IncreaseMinute); + DecreaseMinuteCommand = ReactiveCommand.Create(DecreaseMinute); + } + + public void Set(int minute) + { + Minute = Math.Clamp(minute, 0, 59); + } + + public int Get() + { + return Minute; + } + + private void IncreaseMinute() + { + Minute = Minute >= 59 ? 0 : Minute + 1; + } + + private void DecreaseMinute() + { + Minute = Minute <= 0 ? 59 : Minute - 1; + } + + public ReactiveCommand IncreaseMinuteCommand { get; } + public ReactiveCommand DecreaseMinuteCommand { get; } + + [Reactive] + private int _minute = 0; + } +} \ No newline at end of file diff --git a/WPFUI/Views/Tabs/AccountSettingTab.xaml b/WPFUI/Views/Tabs/AccountSettingTab.xaml index 339c00ba..0b0d5680 100644 --- a/WPFUI/Views/Tabs/AccountSettingTab.xaml +++ b/WPFUI/Views/Tabs/AccountSettingTab.xaml @@ -41,8 +41,17 @@ - - + + + + + + + + + + + diff --git a/WPFUI/Views/Tabs/AccountSettingTab.xaml.cs b/WPFUI/Views/Tabs/AccountSettingTab.xaml.cs index 239ab283..19938b3c 100644 --- a/WPFUI/Views/Tabs/AccountSettingTab.xaml.cs +++ b/WPFUI/Views/Tabs/AccountSettingTab.xaml.cs @@ -24,8 +24,11 @@ public AccountSettingTab() this.Bind(ViewModel, vm => vm.AccountSettingInput.ClickDelay, v => v.ClickDelay.ViewModel).DisposeWith(d); this.Bind(ViewModel, vm => vm.AccountSettingInput.TaskDelay, v => v.TaskDelay.ViewModel).DisposeWith(d); - this.Bind(ViewModel, vm => vm.AccountSettingInput.WorkTime, v => v.WorkTime.ViewModel).DisposeWith(d); - this.Bind(ViewModel, vm => vm.AccountSettingInput.SleepTime, v => v.SleepTime.ViewModel).DisposeWith(d); + this.Bind(ViewModel, vm => vm.AccountSettingInput.WorkStartHour, v => v.WorkStartHour.ViewModel).DisposeWith(d); + this.Bind(ViewModel, vm => vm.AccountSettingInput.WorkStartMinute, v => v.WorkStartMinute.ViewModel).DisposeWith(d); + this.Bind(ViewModel, vm => vm.AccountSettingInput.WorkEndHour, v => v.WorkEndHour.ViewModel).DisposeWith(d); + this.Bind(ViewModel, vm => vm.AccountSettingInput.WorkEndMinute, v => v.WorkEndMinute.ViewModel).DisposeWith(d); + this.Bind(ViewModel, vm => vm.AccountSettingInput.RandomMinute, v => v.RandomMinute.ViewModel).DisposeWith(d); this.Bind(ViewModel, vm => vm.AccountSettingInput.EnableAutoLoadVillage, v => v.EnableAutoLoadVillage.IsChecked).DisposeWith(d); this.Bind(ViewModel, vm => vm.AccountSettingInput.Tribe, v => v.Tribes.ViewModel).DisposeWith(d); this.Bind(ViewModel, vm => vm.AccountSettingInput.HeadlessChrome, v => v.HeadlessChrome.IsChecked).DisposeWith(d); diff --git a/WPFUI/Views/UserControls/HourInputUc.xaml b/WPFUI/Views/UserControls/HourInputUc.xaml new file mode 100644 index 00000000..73296fe5 --- /dev/null +++ b/WPFUI/Views/UserControls/HourInputUc.xaml @@ -0,0 +1,32 @@ + + + + diff --git a/WPFUI/Views/UserControls/HourInputUc.xaml.cs b/WPFUI/Views/UserControls/HourInputUc.xaml.cs new file mode 100644 index 00000000..72a2fc70 --- /dev/null +++ b/WPFUI/Views/UserControls/HourInputUc.xaml.cs @@ -0,0 +1,41 @@ +using MainCore.UI.ViewModels.UserControls; +using ReactiveUI; +using System.Reactive.Disposables.Fluent; +using System.Windows; + +namespace WPFUI.Views.UserControls +{ + public class HourInputUcBase : ReactiveUserControl + { + } + + /// + /// Interaction logic for HourInputUc.xaml + /// + public partial class HourInputUc : HourInputUcBase + { + public HourInputUc() + { + InitializeComponent(); + this.WhenActivated(d => + { + this.Bind(ViewModel, vm => vm.Hour, v => v.HourValue.Text).DisposeWith(d); + + this.BindCommand(ViewModel, vm => vm.IncreaseHourCommand, v => v.IncreaseButton) + .DisposeWith(d); + + this.BindCommand(ViewModel, vm => vm.DecreaseHourCommand, v => v.DecreaseButton) + .DisposeWith(d); + }); + } + + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register("Text", typeof(string), typeof(HourInputUc), new PropertyMetadata(default(string))); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + } +} diff --git a/WPFUI/Views/UserControls/MinuteInputUc.xaml b/WPFUI/Views/UserControls/MinuteInputUc.xaml new file mode 100644 index 00000000..344c3344 --- /dev/null +++ b/WPFUI/Views/UserControls/MinuteInputUc.xaml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/WPFUI/Views/UserControls/MinuteInputUc.xaml.cs b/WPFUI/Views/UserControls/MinuteInputUc.xaml.cs new file mode 100644 index 00000000..9e17de69 --- /dev/null +++ b/WPFUI/Views/UserControls/MinuteInputUc.xaml.cs @@ -0,0 +1,41 @@ +using MainCore.UI.ViewModels.UserControls; +using ReactiveUI; +using System.Reactive.Disposables.Fluent; +using System.Windows; + +namespace WPFUI.Views.UserControls +{ + public class MinuteInputUcBase : ReactiveUserControl + { + } + + /// + /// Interaction logic for MinuteInputUc.xaml + /// + public partial class MinuteInputUc : MinuteInputUcBase + { + public MinuteInputUc() + { + InitializeComponent(); + this.WhenActivated(d => + { + this.Bind(ViewModel, vm => vm.Minute, v => v.MinuteValue.Text).DisposeWith(d); + + this.BindCommand(ViewModel, vm => vm.IncreaseMinuteCommand, v => v.IncreaseButton) + .DisposeWith(d); + + this.BindCommand(ViewModel, vm => vm.DecreaseMinuteCommand, v => v.DecreaseButton) + .DisposeWith(d); + }); + } + + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register("Text", typeof(string), typeof(MinuteInputUc), new PropertyMetadata(default(string))); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + } +} \ No newline at end of file