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 };