Phase 40: clock tempo at 1 min/s, x5/x10 heavy stride.

This commit is contained in:
Leonid Pershin
2026-08-20 07:02:24 +03:00
parent 2a55a025f4
commit 9199ea36d2
16 changed files with 192 additions and 57 deletions
+3 -3
View File
@@ -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 - **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. 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 - **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 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. 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 | | `TickRate` | 20 | fixed simulation steps per second |
| `MaxSchools` | 6 | how many schools may exist at once | | `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 | | `DefaultStartDate` | `2012-03-31T06:00:00` | prefilled start of a new school |
| `SavesDirectory` | `saves` | per-school JSON files | | `SavesDirectory` | `saves` | per-school JSON files |
| `ModsDirectory` | `mods` | pack folders; `core` is required | | `ModsDirectory` | `mods` | pack folders; `core` is required |
+12 -12
View File
@@ -12,26 +12,26 @@
## Задачи ## Задачи
- [ ] `GameMinutesPerRealSecond` по умолчанию 1 (опции, `appsettings.json`, `GET /api/schools`) - [x] `GameMinutesPerRealSecond` по умолчанию 1 (опции, `appsettings.json`, `GET /api/schools`)
- [ ] `ClockSpeed.Multipliers`: `0.5, 1, 2, 5, 10`. Зеркало в `protocol.ts`. Подписи кнопок: - [x] `ClockSpeed.Multipliers`: `0.5, 1, 2, 5, 10`. Зеркало в `protocol.ts`. Подписи кнопок:
×½ ×1 ×2 ×5 ×10. Стартовый индекс по-прежнему 1 ×½ ×1 ×2 ×5 ×10. Стартовый индекс по-прежнему 1
- [ ] Воркер будит мир 20 раз в секунду на любой скорости. `GameClock.Advance`, граница лога - [x] Воркер будит мир 20 раз в секунду на любой скорости. `GameClock.Advance`, граница лога
6:00, годовой набор, пул соискателей, погода — каждый импульс 6:00, годовой набор, пул соискателей, погода — каждый импульс
- [ ] На индексах ×5 и ×10 ходьба, действия, решения, нужды, тепло, обучение, износ — раз в - [x] На индексах ×5 и ×10 ходьба, действия, решения, нужды, тепло, обучение, износ — раз в
4 и раз в 2 импульса соответственно, с накопленными минутами (квант — 1 игровая минута). 4 и раз в 2 импульса соответственно, с накопленными минутами (квант — 1 игровая минута).
На ×½ ×1 ×2 — каждый импульс, как сейчас На ×½ ×1 ×2 — каждый импульс, как сейчас
- [ ] Не прыгать календарём одним жирным `Tick` вместо страйда - [x] Не прыгать календарём одним жирным `Tick` вместо страйда
- [ ] Комментарий меню про «минута за 12 секунд» привести к новой базе - [x] Комментарий меню про «минута за 12 секунд» привести к новой базе
- [ ] `docs/protocol.md` — таблица множителей. Раскладку и версию сокета не трогать - [x] `docs/protocol.md` — таблица множителей. Раскладку и версию сокета не трогать
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] 20 шагов по 1/20 с на ×1 сдвигают календарь на 1 игровую минуту, не на 5 - [x] 20 шагов по 1/20 с на ×1 сдвигают календарь на 1 игровую минуту, не на 5
- [ ] Индексы 3 и 4 за одну реальную секунду дают 5 и 10 игровых минут - [x] Индексы 3 и 4 за одну реальную секунду дают 5 и 10 игровых минут
- [ ] На ×10 за 20 импульсов ходьба/нужды вызваны 10 раз, календарь — 20; накопленный аргумент - [x] На ×10 за 20 импульсов ходьба/нужды вызваны 10 раз, календарь — 20; накопленный аргумент
минут на тяжёлый вызов — 1 минут на тяжёлый вызов — 1
- [ ] Невалидный индекс 5 по-прежнему игнорируется - [x] Невалидный индекс 5 по-прежнему игнорируется
- [ ] Клиентский тест: пять кнопок ×½ ×1 ×2 ×5 ×10 - [x] Клиентский тест: пять кнопок ×½ ×1 ×2 ×5 ×10
## Критерий готовности ## Критерий готовности
+4 -4
View File
@@ -39,7 +39,7 @@ people; it does not change on a living school.
{ {
"maxSchools": 6, "maxSchools": 6,
"defaultStartDate": "2012-03-31T06:00:00Z", "defaultStartDate": "2012-03-31T06:00:00Z",
"gameMinutesPerRealSecond": 5, "gameMinutesPerRealSecond": 1,
"schoolWeekDays": 5, "schoolWeekDays": 5,
"schools": [ "schools": [
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291 } { "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 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. 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 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` (5), so ×1 is five game ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (1), so ×1 is one game
minutes per real second. minute per real second.
### `0x07` SkipEmpty — 1 byte ### `0x07` SkipEmpty — 1 byte
+1 -1
View File
@@ -327,6 +327,6 @@ describe('decodeServerMessage', () => {
describe('clock speeds', () => { describe('clock speeds', () => {
it('matches the server table', () => { 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]);
}); });
}); });
+1 -1
View File
@@ -33,7 +33,7 @@ export const WireLocale = {
* Speed buttons, in wire order — the index travels, not the multiplier. * Speed buttons, in wire order — the index travels, not the multiplier.
* Mirror of `ClockSpeed.Multipliers` in `src/HSchool.Simulation/ClockSpeed.cs`. * 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; export const DEFAULT_SPEED_INDEX = 1;
@@ -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']);
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ interface GameScreenOptions {
readonly onSkip: () => void; 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 * The inside of a school: calendar controls plus the manager shell. The tree comes from one map
+1 -1
View File
@@ -23,7 +23,7 @@ interface MainMenuOptions {
* timer — the server is the only thing that knows what time it is in each of them. * 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 * 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; const REFRESH_INTERVAL_MS = 1000;
+1 -1
View File
@@ -14,7 +14,7 @@
"Simulation": { "Simulation": {
"TickRate": 20, "TickRate": 20,
"MaxSchools": 6, "MaxSchools": 6,
"GameMinutesPerRealSecond": 5, "GameMinutesPerRealSecond": 1,
"DefaultStartDate": "2012-03-31T06:00:00", "DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves", "SavesDirectory": "saves",
"ModsDirectory": "mods", "ModsDirectory": "mods",
+20 -2
View File
@@ -6,8 +6,8 @@ namespace HSchool.Simulation;
/// </summary> /// </summary>
public static class ClockSpeed public static class ClockSpeed
{ {
/// <summary>×½, ×1, ×2, ×3, ×4.</summary> /// <summary>×½, ×1, ×2, ×5, ×10.</summary>
public static ReadOnlySpan<double> Multipliers => [0.5d, 1d, 2d, 3d, 4d]; public static ReadOnlySpan<double> Multipliers => [0.5d, 1d, 2d, 5d, 10d];
/// <summary>Index of ×1, the speed a school starts at.</summary> /// <summary>Index of ×1, the speed a school starts at.</summary>
public const int DefaultIndex = 1; public const int DefaultIndex = 1;
@@ -18,4 +18,22 @@ public static class ClockSpeed
/// <summary>Multiplier for a validated index; out-of-range values fall back to ×1.</summary> /// <summary>Multiplier for a validated index; out-of-range values fall back to ×1.</summary>
public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex]; public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex];
/// <summary>
/// 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.
/// </summary>
public static int HeavySystemsStride(int speedIndex) => speedIndex switch
{
3 => 4,
4 => 2,
_ => 1,
};
/// <summary>
/// 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.
/// </summary>
public static double HeavyGameMinutes(int speedIndex, double impulseMinutes) =>
speedIndex is 3 or 4 ? 1d : impulseMinutes;
} }
+40 -16
View File
@@ -116,6 +116,12 @@ public sealed class School : IDisposable
internal int DecisionBudget { get; set; } 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; public int PendingDecisionCount => DecisionQueue.Count;
/// <summary> /// <summary>
@@ -303,24 +309,14 @@ public sealed class School : IDisposable
LastDecisionSlot = null; LastDecisionSlot = null;
} }
PresenceSystem.Apply(this, gameMinutes); SyncWeather(force: false);
foreach (var id in ActivitySystem.Apply(this, gameMinutes))
{
PresenceSystem.Enqueue(this, id);
}
PersonDayLog.Sync(this); _impulseCounter++;
var stride = ClockSpeed.HeavySystemsStride(Clock.SpeedIndex);
if (Catalog is not null) if (_impulseCounter % stride == 0)
{ {
var below = PresenceSystem.BelowThreshold(this); var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes);
SyncWeather(force: false); peopleChanged |= ApplyHeavySystems(heavyMinutes);
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);
} }
} }
else else
@@ -331,6 +327,34 @@ public sealed class School : IDisposable
return peopleChanged; 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;
}
/// <summary> /// <summary>
/// Recomputes the street from the preset, seed and current time. Commits when the clock /// 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 /// tenths or precipitation change, so warmth does not jitter every tick. A skip must force
+1 -1
View File
@@ -14,7 +14,7 @@ public sealed class SimulationOptions
public int MaxSchools { get; set; } = 6; public int MaxSchools { get; set; } = 6;
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary> /// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
public double GameMinutesPerRealSecond { get; set; } = 5d; public double GameMinutesPerRealSecond { get; set; } = 1d;
/// <summary>Prefilled start of a new school; the client shows it in the creation form.</summary> /// <summary>Prefilled start of a new school; the client shows it in the creation form.</summary>
public DateTime DefaultStartDate public DateTime DefaultStartDate
@@ -83,11 +83,11 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.True(first.Running); Assert.True(first.Running);
Assert.Equal(1, first.SpeedIndex); 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 later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
var elapsed = ToDate(later) - ToDate(first); var elapsed = ToDate(later) - ToDate(first);
Assert.InRange(elapsed.TotalMinutes, 3, 8); Assert.InRange(elapsed.TotalMinutes, 0.5, 2);
} }
[Fact] [Fact]
@@ -208,7 +208,7 @@ public class GameSocketTests(AppHostFixture fixture)
// presence, not the snapshot" means. Do not wait for the freshly hired teacher in // 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, // 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 // 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. // one person made this test fail under load.
await SendAsync(socket, buffer => await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); 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 later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
var elapsed = ToDate(later) - ToDate(fast); var elapsed = ToDate(later) - ToDate(fast);
// ×4 means 20 game minutes per real second. // ×10 means 10 game minutes per real second.
Assert.InRange(elapsed.TotalMinutes, 12, 30); Assert.InRange(elapsed.TotalMinutes, 6, 14);
} }
[Fact] [Fact]
@@ -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 // Without the "Z" the browser would read the start date in its own time zone and the
// creation form would offer the wrong hour. // creation form would offer the wrong hour.
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind); Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
Assert.Equal(5d, state.GameMinutesPerRealSecond); Assert.Equal(1d, state.GameMinutesPerRealSecond);
Assert.Equal(5, state.SchoolWeekDays); Assert.Equal(5, state.SchoolWeekDays);
Assert.Empty(state.Schools); Assert.Empty(state.Schools);
} }
@@ -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);
}
}
@@ -5,7 +5,7 @@ public class GameClockTests
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private const double OneTwentiethOfASecond = 1d / 20d; private const double OneTwentiethOfASecond = 1d / 20d;
private const double GameMinutesPerRealSecond = 5d; private const double GameMinutesPerRealSecond = 1d;
[Fact] [Fact]
public void NewClock_StartsRunningAtTheStartDate() public void NewClock_StartsRunningAtTheStartDate()
@@ -32,7 +32,7 @@ public class GameClockTests
} }
[Fact] [Fact]
public void OneRealSecond_AdvancesFiveGameMinutes() public void OneRealSecond_AdvancesOneGameMinute()
{ {
var clock = new GameClock(Start); var clock = new GameClock(Start);
@@ -42,15 +42,28 @@ public class GameClockTests
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond); 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] [Theory]
[InlineData(0, 2.5)] [InlineData(0, 0.5)]
[InlineData(1, 5)] [InlineData(1, 1)]
[InlineData(2, 10)] [InlineData(2, 2)]
[InlineData(3, 15)] [InlineData(3, 5)]
[InlineData(4, 20)] [InlineData(4, 10)]
public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes) public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes)
{ {
var clock = new GameClock(Start) { SpeedIndex = speedIndex }; var clock = new GameClock(Start) { SpeedIndex = speedIndex };