From 9199ea36d2882f2cf5dc53bf4bfe81ad7ce9848d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:02:24 +0300 Subject: [PATCH] Phase 40: clock tempo at 1 min/s, x5/x10 heavy stride. --- README.md | 6 +- docs/phases/40-clock-tempo.md | 24 ++++---- docs/protocol.md | 8 +-- src/HSchool.Client/src/net/protocol.test.ts | 2 +- src/HSchool.Client/src/net/protocol.ts | 2 +- src/HSchool.Client/src/ui/gameScreen.test.ts | 25 +++++++++ src/HSchool.Client/src/ui/gameScreen.ts | 2 +- src/HSchool.Client/src/ui/mainMenu.ts | 2 +- src/HSchool.Server/appsettings.json | 2 +- src/HSchool.Simulation/ClockSpeed.cs | 22 +++++++- src/HSchool.Simulation/School.cs | 56 +++++++++++++------ src/HSchool.Simulation/SimulationOptions.cs | 2 +- .../HSchool.AppHost.Tests/GameSocketTests.cs | 10 ++-- tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 2 +- .../ClockTempoTests.cs | 55 ++++++++++++++++++ .../GameClockTests.cs | 29 +++++++--- 16 files changed, 192 insertions(+), 57 deletions(-) create mode 100644 src/HSchool.Client/src/ui/gameScreen.test.ts create mode 100644 tests/HSchool.Simulation.Tests/ClockTempoTests.cs diff --git a/README.md b/README.md index 4bd8b8b..108c820 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ rather than guessing. - **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00 by default). At six schools the create button is disabled and says why. - **Inside a school** — the date, time and weekday of the game calendar, play/pause and the - ×½ ×1 ×2 ×3 ×4 speed buttons, plus a way back to the menu. + ×½ ×1 ×2 ×5 ×10 speed buttons, plus a way back to the menu. -Time runs at 5 game minutes per real second at ×1. **Every school runs on its own**, whether or +Time runs at 1 game minute per real second at ×1. **Every school runs on its own**, whether or not you are inside it — the menu cards keep counting. Only the pause button stops a school, and it stays paused (the card says so) until you press play again. @@ -113,7 +113,7 @@ Simulation tunables live under the `Simulation` section of | --- | --- | --- | | `TickRate` | 20 | fixed simulation steps per second | | `MaxSchools` | 6 | how many schools may exist at once | -| `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 | +| `GameMinutesPerRealSecond` | 1 | game minutes per real second at ×1 | | `DefaultStartDate` | `2012-03-31T06:00:00` | prefilled start of a new school | | `SavesDirectory` | `saves` | per-school JSON files | | `ModsDirectory` | `mods` | pack folders; `core` is required | diff --git a/docs/phases/40-clock-tempo.md b/docs/phases/40-clock-tempo.md index 1bd4f6b..06999c1 100644 --- a/docs/phases/40-clock-tempo.md +++ b/docs/phases/40-clock-tempo.md @@ -12,26 +12,26 @@ ## Задачи -- [ ] `GameMinutesPerRealSecond` по умолчанию 1 (опции, `appsettings.json`, `GET /api/schools`) -- [ ] `ClockSpeed.Multipliers`: `0.5, 1, 2, 5, 10`. Зеркало в `protocol.ts`. Подписи кнопок: +- [x] `GameMinutesPerRealSecond` по умолчанию 1 (опции, `appsettings.json`, `GET /api/schools`) +- [x] `ClockSpeed.Multipliers`: `0.5, 1, 2, 5, 10`. Зеркало в `protocol.ts`. Подписи кнопок: ×½ ×1 ×2 ×5 ×10. Стартовый индекс по-прежнему 1 -- [ ] Воркер будит мир 20 раз в секунду на любой скорости. `GameClock.Advance`, граница лога +- [x] Воркер будит мир 20 раз в секунду на любой скорости. `GameClock.Advance`, граница лога 6:00, годовой набор, пул соискателей, погода — каждый импульс -- [ ] На индексах ×5 и ×10 ходьба, действия, решения, нужды, тепло, обучение, износ — раз в +- [x] На индексах ×5 и ×10 ходьба, действия, решения, нужды, тепло, обучение, износ — раз в 4 и раз в 2 импульса соответственно, с накопленными минутами (квант — 1 игровая минута). На ×½ ×1 ×2 — каждый импульс, как сейчас -- [ ] Не прыгать календарём одним жирным `Tick` вместо страйда -- [ ] Комментарий меню про «минута за 12 секунд» привести к новой базе -- [ ] `docs/protocol.md` — таблица множителей. Раскладку и версию сокета не трогать +- [x] Не прыгать календарём одним жирным `Tick` вместо страйда +- [x] Комментарий меню про «минута за 12 секунд» привести к новой базе +- [x] `docs/protocol.md` — таблица множителей. Раскладку и версию сокета не трогать ## Тесты, без которых фаза не закрыта -- [ ] 20 шагов по 1/20 с на ×1 сдвигают календарь на 1 игровую минуту, не на 5 -- [ ] Индексы 3 и 4 за одну реальную секунду дают 5 и 10 игровых минут -- [ ] На ×10 за 20 импульсов ходьба/нужды вызваны 10 раз, календарь — 20; накопленный аргумент +- [x] 20 шагов по 1/20 с на ×1 сдвигают календарь на 1 игровую минуту, не на 5 +- [x] Индексы 3 и 4 за одну реальную секунду дают 5 и 10 игровых минут +- [x] На ×10 за 20 импульсов ходьба/нужды вызваны 10 раз, календарь — 20; накопленный аргумент минут на тяжёлый вызов — 1 -- [ ] Невалидный индекс 5 по-прежнему игнорируется -- [ ] Клиентский тест: пять кнопок ×½ ×1 ×2 ×5 ×10 +- [x] Невалидный индекс 5 по-прежнему игнорируется +- [x] Клиентский тест: пять кнопок ×½ ×1 ×2 ×5 ×10 ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index c7e95ab..9599e44 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -39,7 +39,7 @@ people; it does not change on a living school. { "maxSchools": 6, "defaultStartDate": "2012-03-31T06:00:00Z", - "gameMinutesPerRealSecond": 5, + "gameMinutesPerRealSecond": 1, "schoolWeekDays": 5, "schools": [ { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291 } @@ -753,9 +753,9 @@ Running and speed are **separate messages on purpose**. A single "set clock" mes button to resend the other field from the client's own copy of the state, which is always at least one tick stale — pressing play and then a speed button would pause the school again. -Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of-range values are -ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game -minutes per real second. +Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×5`, `4 = ×10`; out-of-range values are +ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (1), so ×1 is one game +minute per real second. ### `0x07` SkipEmpty — 1 byte diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 1ecdd56..7a90d4c 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -327,6 +327,6 @@ describe('decodeServerMessage', () => { describe('clock speeds', () => { it('matches the server table', () => { - expect([...CLOCK_SPEEDS]).toEqual([0.5, 1, 2, 3, 4]); + expect([...CLOCK_SPEEDS]).toEqual([0.5, 1, 2, 5, 10]); }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 8dfbc35..2b00a7b 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -33,7 +33,7 @@ export const WireLocale = { * Speed buttons, in wire order — the index travels, not the multiplier. * Mirror of `ClockSpeed.Multipliers` in `src/HSchool.Simulation/ClockSpeed.cs`. */ -export const CLOCK_SPEEDS = [0.5, 1, 2, 3, 4] as const; +export const CLOCK_SPEEDS = [0.5, 1, 2, 5, 10] as const; export const DEFAULT_SPEED_INDEX = 1; diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts new file mode 100644 index 0000000..823ca01 --- /dev/null +++ b/src/HSchool.Client/src/ui/gameScreen.test.ts @@ -0,0 +1,25 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it, vi } from 'vitest'; +import { GameScreen } from './gameScreen.ts'; + +describe('GameScreen speed buttons', () => { + it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => { + const onSetSpeed = vi.fn(); + const screen = new GameScreen({ + onLeave: () => {}, + onSetRunning: () => {}, + onSetSpeed, + onSkip: () => {}, + }); + + document.body.append(screen.element); + + const labels = [...screen.element.querySelectorAll('.clock__controls .button--small')] + .map((button) => button.textContent ?? '') + .filter((text) => text.startsWith('×')); + + expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']); + }); +}); diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 0e8c6a0..ed65350 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -24,7 +24,7 @@ interface GameScreenOptions { readonly onSkip: () => void; } -const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4']; +const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10']; /** * The inside of a school: calendar controls plus the manager shell. The tree comes from one map diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 50c94e5..859f68e 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -23,7 +23,7 @@ interface MainMenuOptions { * timer — the server is the only thing that knows what time it is in each of them. * * One second is well below the resolution the cards show: at ×1 a game minute passes every - * 12 real seconds, at ×4 every 3. + * real second, at ×10 every tenth of a second on the clock (still one poll per second here). */ const REFRESH_INTERVAL_MS = 1000; diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index cbb9ff0..4730435 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -14,7 +14,7 @@ "Simulation": { "TickRate": 20, "MaxSchools": 6, - "GameMinutesPerRealSecond": 5, + "GameMinutesPerRealSecond": 1, "DefaultStartDate": "2012-03-31T06:00:00", "SavesDirectory": "saves", "ModsDirectory": "mods", diff --git a/src/HSchool.Simulation/ClockSpeed.cs b/src/HSchool.Simulation/ClockSpeed.cs index 19413f5..37a102b 100644 --- a/src/HSchool.Simulation/ClockSpeed.cs +++ b/src/HSchool.Simulation/ClockSpeed.cs @@ -6,8 +6,8 @@ namespace HSchool.Simulation; /// public static class ClockSpeed { - /// ×½, ×1, ×2, ×3, ×4. - public static ReadOnlySpan Multipliers => [0.5d, 1d, 2d, 3d, 4d]; + /// ×½, ×1, ×2, ×5, ×10. + public static ReadOnlySpan Multipliers => [0.5d, 1d, 2d, 5d, 10d]; /// Index of ×1, the speed a school starts at. public const int DefaultIndex = 1; @@ -18,4 +18,22 @@ public static class ClockSpeed /// Multiplier for a validated index; out-of-range values fall back to ×1. public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex]; + + /// + /// Calendar impulses between heavy-system ticks. ×5 and ×10 stride so walking and needs keep + /// one game minute per heavy step instead of twenty tiny ones per second. + /// + public static int HeavySystemsStride(int speedIndex) => speedIndex switch + { + 3 => 4, + 4 => 2, + _ => 1, + }; + + /// + /// Game minutes passed into presence, needs and learning on a heavy tick. High speeds use a + /// fixed one-minute quantum; lower speeds pass the impulse's own advance. + /// + public static double HeavyGameMinutes(int speedIndex, double impulseMinutes) => + speedIndex is 3 or 4 ? 1d : impulseMinutes; } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index c3d1a2d..bfcd068 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -116,6 +116,12 @@ public sealed class School : IDisposable internal int DecisionBudget { get; set; } + internal int HeavySystemsInvocations { get; private set; } + + internal double LastHeavyGameMinutes { get; private set; } + + private int _impulseCounter; + public int PendingDecisionCount => DecisionQueue.Count; /// @@ -303,24 +309,14 @@ public sealed class School : IDisposable LastDecisionSlot = null; } - PresenceSystem.Apply(this, gameMinutes); - foreach (var id in ActivitySystem.Apply(this, gameMinutes)) - { - PresenceSystem.Enqueue(this, id); - } + SyncWeather(force: false); - PersonDayLog.Sync(this); - - if (Catalog is not null) + _impulseCounter++; + var stride = ClockSpeed.HeavySystemsStride(Clock.SpeedIndex); + if (_impulseCounter % stride == 0) { - var below = PresenceSystem.BelowThreshold(this); - SyncWeather(force: false); - NeedDecay.Apply(World, Catalog, gameMinutes); - WarmthDecay.Apply(this, gameMinutes); - PresenceSystem.EnqueueNewlyUrgent(this, below); - PresenceSystem.DrainDecisions(this); - LessonLearningSystem.Apply(this, gameMinutes); - peopleChanged |= ApparelWear.Apply(this, gameMinutes, before); + var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes); + peopleChanged |= ApplyHeavySystems(heavyMinutes); } } else @@ -331,6 +327,34 @@ public sealed class School : IDisposable return peopleChanged; } + private bool ApplyHeavySystems(double gameMinutes) + { + HeavySystemsInvocations++; + LastHeavyGameMinutes = gameMinutes; + + var peopleChanged = false; + PresenceSystem.Apply(this, gameMinutes); + foreach (var id in ActivitySystem.Apply(this, gameMinutes)) + { + PresenceSystem.Enqueue(this, id); + } + + PersonDayLog.Sync(this); + + if (Catalog is not null) + { + var below = PresenceSystem.BelowThreshold(this); + NeedDecay.Apply(World, Catalog, gameMinutes); + WarmthDecay.Apply(this, gameMinutes); + PresenceSystem.EnqueueNewlyUrgent(this, below); + PresenceSystem.DrainDecisions(this); + LessonLearningSystem.Apply(this, gameMinutes); + peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes)); + } + + return peopleChanged; + } + /// /// Recomputes the street from the preset, seed and current time. Commits when the clock /// tenths or precipitation change, so warmth does not jitter every tick. A skip must force diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index 6ebb783..e9ecd09 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -14,7 +14,7 @@ public sealed class SimulationOptions public int MaxSchools { get; set; } = 6; /// Base speed of the game clock: real seconds are multiplied by this many game minutes. - public double GameMinutesPerRealSecond { get; set; } = 5d; + public double GameMinutesPerRealSecond { get; set; } = 1d; /// Prefilled start of a new school; the client shows it in the creation form. public DateTime DefaultStartDate diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 0965a05..4308665 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -83,11 +83,11 @@ public class GameSocketTests(AppHostFixture fixture) Assert.True(first.Running); Assert.Equal(1, first.SpeedIndex); - // 5 game minutes per real second at ×1, so one second of ticks has to move the calendar. + // 1 game minute per real second at ×1, so one second of ticks has to move the calendar. var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1)); var elapsed = ToDate(later) - ToDate(first); - Assert.InRange(elapsed.TotalMinutes, 3, 8); + Assert.InRange(elapsed.TotalMinutes, 0.5, 2); } [Fact] @@ -208,7 +208,7 @@ public class GameSocketTests(AppHostFixture fixture) // presence, not the snapshot" means. Do not wait for the freshly hired teacher in // particular: whether somebody hired mid-day comes in today depends on their day plan, // and the frozen moment drifts with however long this test's own HTTP calls took (the - // school runs at five game minutes per real second until it is paused). Waiting for that + // school runs at one game minute per real second until it is paused). Waiting for that // one person made this test fail under load. await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); @@ -281,8 +281,8 @@ public class GameSocketTests(AppHostFixture fixture) var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1)); var elapsed = ToDate(later) - ToDate(fast); - // ×4 means 20 game minutes per real second. - Assert.InRange(elapsed.TotalMinutes, 12, 30); + // ×10 means 10 game minutes per real second. + Assert.InRange(elapsed.TotalMinutes, 6, 14); } [Fact] diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 8b05060..33f47b2 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -25,7 +25,7 @@ public class SchoolApiTests(AppHostFixture fixture) // Without the "Z" the browser would read the start date in its own time zone and the // creation form would offer the wrong hour. Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind); - Assert.Equal(5d, state.GameMinutesPerRealSecond); + Assert.Equal(1d, state.GameMinutesPerRealSecond); Assert.Equal(5, state.SchoolWeekDays); Assert.Empty(state.Schools); } diff --git a/tests/HSchool.Simulation.Tests/ClockTempoTests.cs b/tests/HSchool.Simulation.Tests/ClockTempoTests.cs new file mode 100644 index 0000000..24e2e46 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/ClockTempoTests.cs @@ -0,0 +1,55 @@ +namespace HSchool.Simulation.Tests; + +public class ClockTempoTests +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + private const double OneTwentiethOfASecond = 1d / 20d; + private const double GameMinutesPerRealSecond = 1d; + + [Fact] + public void AtX10_TwentyImpulses_RunHeavyTenTimesAndAdvanceTenGameMinutes() + { + using var school = School.Create(1, "Страйд", Start); + school.Clock.SpeedIndex = 4; + + for (var i = 0; i < 20; i++) + { + school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond); + } + + Assert.Equal(Start.AddMinutes(10), school.Clock.Time); + Assert.Equal(10, school.HeavySystemsInvocations); + Assert.Equal(1d, school.LastHeavyGameMinutes); + } + + [Fact] + public void AtX1_EveryImpulseRunsHeavySystems() + { + using var school = School.Create(1, "Каждый импульс", Start); + school.Clock.SpeedIndex = 1; + + for (var i = 0; i < 20; i++) + { + school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond); + } + + Assert.Equal(20, school.HeavySystemsInvocations); + } + + [Theory] + [InlineData(3, 5d)] + [InlineData(4, 10d)] + public void HighSpeedIndex_OneRealSecond_AdvancesExpectedGameMinutes(int speedIndex, double expectedMinutes) + { + using var school = School.Create(1, "Быстро", Start); + school.Clock.SpeedIndex = speedIndex; + + for (var i = 0; i < 20; i++) + { + school.Tick(OneTwentiethOfASecond, GameMinutesPerRealSecond); + } + + Assert.Equal(Start.AddMinutes(expectedMinutes), school.Clock.Time); + } +} diff --git a/tests/HSchool.Simulation.Tests/GameClockTests.cs b/tests/HSchool.Simulation.Tests/GameClockTests.cs index bd7a7b9..5cb8e13 100644 --- a/tests/HSchool.Simulation.Tests/GameClockTests.cs +++ b/tests/HSchool.Simulation.Tests/GameClockTests.cs @@ -5,7 +5,7 @@ public class GameClockTests private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); private const double OneTwentiethOfASecond = 1d / 20d; - private const double GameMinutesPerRealSecond = 5d; + private const double GameMinutesPerRealSecond = 1d; [Fact] public void NewClock_StartsRunningAtTheStartDate() @@ -32,7 +32,7 @@ public class GameClockTests } [Fact] - public void OneRealSecond_AdvancesFiveGameMinutes() + public void OneRealSecond_AdvancesOneGameMinute() { var clock = new GameClock(Start); @@ -42,15 +42,28 @@ public class GameClockTests clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond); } - Assert.Equal(Start.AddMinutes(5), clock.Time); + Assert.Equal(Start.AddMinutes(1), clock.Time); + } + + [Fact] + public void TwentyStepsAtX1_AdvanceOneGameMinuteNotFive() + { + var clock = new GameClock(Start) { SpeedIndex = 1 }; + + for (var i = 0; i < 20; i++) + { + clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond); + } + + Assert.Equal(Start.AddMinutes(1), clock.Time); } [Theory] - [InlineData(0, 2.5)] - [InlineData(1, 5)] - [InlineData(2, 10)] - [InlineData(3, 15)] - [InlineData(4, 20)] + [InlineData(0, 0.5)] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 5)] + [InlineData(4, 10)] public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes) { var clock = new GameClock(Start) { SpeedIndex = speedIndex };