Give each school its own people seed so composition no longer follows create order.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -55,6 +55,10 @@ const ru = {
|
||||
editMap: 'Редактировать карту',
|
||||
mapDefaultHint: 'Будет использована карта по умолчанию.',
|
||||
mapEditedHint: 'Карта изменена.',
|
||||
schoolSeed: 'Сид {seed}',
|
||||
schoolSeedLabel: 'Сид',
|
||||
schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.',
|
||||
schoolSeedInvalid: 'Сид должен быть целым числом.',
|
||||
resetMap: 'Сбросить к умолчанию',
|
||||
done: 'Готово',
|
||||
editorStructure: 'Структура',
|
||||
@@ -266,6 +270,10 @@ const en: Messages = {
|
||||
editMap: 'Edit map',
|
||||
mapDefaultHint: 'The default map will be used.',
|
||||
mapEditedHint: 'The map has been edited.',
|
||||
schoolSeed: 'Seed {seed}',
|
||||
schoolSeedLabel: 'Seed',
|
||||
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
|
||||
schoolSeedInvalid: 'The seed must be a whole number.',
|
||||
resetMap: 'Reset to default',
|
||||
done: 'Done',
|
||||
editorStructure: 'Structure',
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface School {
|
||||
readonly gameTime: string;
|
||||
readonly running: boolean;
|
||||
readonly speedIndex: number;
|
||||
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
|
||||
readonly seed: number;
|
||||
}
|
||||
|
||||
export interface SchoolsResponse {
|
||||
@@ -51,6 +53,7 @@ export interface CreateSchoolOptions {
|
||||
readonly map?: MapLayout;
|
||||
readonly nameSetId?: string;
|
||||
readonly nativeLanguage?: string;
|
||||
readonly seed?: number;
|
||||
}
|
||||
|
||||
export async function createSchool(
|
||||
@@ -68,6 +71,7 @@ export async function createSchool(
|
||||
map: extras.map ?? null,
|
||||
nameSetId: extras.nameSetId ?? null,
|
||||
nativeLanguage: extras.nativeLanguage ?? null,
|
||||
seed: extras.seed ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,6 +125,21 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.screen__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screen__seed {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.screen__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value;
|
||||
});
|
||||
|
||||
const seedLabel = el('span', { class: 'field__label' });
|
||||
const seedInput = el('input', { class: 'input', type: 'text' });
|
||||
seedInput.inputMode = 'numeric';
|
||||
const seedHint = el('p', { class: 'hint' });
|
||||
const seedField = el('label', { class: 'field' }, seedLabel, seedInput, seedHint);
|
||||
|
||||
const mapLabel = el('span', { class: 'field__label' });
|
||||
const editMapButton = el('button', { class: 'button', type: 'button' });
|
||||
const mapHint = el('p', { class: 'hint' });
|
||||
@@ -107,6 +113,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nameSetField,
|
||||
nativeField,
|
||||
mapField,
|
||||
seedField,
|
||||
error,
|
||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||
);
|
||||
@@ -120,6 +127,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
randomButton.toggleAttribute('disabled', value);
|
||||
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
|
||||
editMapButton.toggleAttribute('disabled', waiting);
|
||||
seedInput.toggleAttribute('disabled', value);
|
||||
};
|
||||
|
||||
const showError = (message: string): void => {
|
||||
@@ -238,6 +246,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nativeLabel.textContent = t('nativeLanguageTitle');
|
||||
nativeRandomButton.textContent = t('randomName');
|
||||
nativeRandomButton.title = t('randomNativeTitle');
|
||||
seedLabel.textContent = t('schoolSeedLabel');
|
||||
seedInput.placeholder = t('schoolSeedLabel');
|
||||
seedHint.textContent = t('schoolSeedHint');
|
||||
nameLabel.textContent = t('schoolName');
|
||||
startLabel.textContent = t('gameStart');
|
||||
mapLabel.textContent = t('mapEditorTitle');
|
||||
@@ -310,6 +321,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
return;
|
||||
}
|
||||
|
||||
const seed = parseSeed(seedInput.value);
|
||||
if (seedInput.value.trim() !== '' && seed === null) {
|
||||
showError(t('schoolSeedInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
options
|
||||
.create(nameInput.value.trim(), startDate, {
|
||||
@@ -317,6 +334,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
map: currentMap,
|
||||
nameSetId: nameSetId ?? undefined,
|
||||
nativeLanguage: nativeLanguageId ?? undefined,
|
||||
seed: seed ?? undefined,
|
||||
})
|
||||
.then((school) => modal.close(school))
|
||||
.catch((reason: unknown) => {
|
||||
@@ -343,6 +361,24 @@ function mapsEqual(left: MapLayout, right: MapLayout): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseSeed(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -2147483648 || parsed > 2147483647) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function describe(reason: unknown): string {
|
||||
if (!(reason instanceof ApiError)) {
|
||||
return t('serverUnavailable');
|
||||
|
||||
@@ -34,6 +34,7 @@ export class GameScreen {
|
||||
private readonly root = el('section', { class: 'screen game' });
|
||||
private readonly backButton = el('button', { class: 'button', type: 'button' });
|
||||
private readonly schoolName = el('h1', { class: 'screen__title' });
|
||||
private readonly schoolSeed = el('p', { class: 'screen__seed' });
|
||||
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
|
||||
private readonly date = el('p', { class: 'clock__date' });
|
||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||
@@ -79,6 +80,7 @@ export class GameScreen {
|
||||
private directoryToken = 0;
|
||||
private selectedId: string | null = null;
|
||||
private schoolId: number | null = null;
|
||||
private peopleSeed: number | null = null;
|
||||
private running = false;
|
||||
private lastGameTime: Date | null = null;
|
||||
private lastSpeedIndex = 0;
|
||||
@@ -102,7 +104,7 @@ export class GameScreen {
|
||||
this.skipButton.hidden = true;
|
||||
|
||||
this.root.append(
|
||||
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
|
||||
el('header', { class: 'screen__header' }, this.backButton, el('div', { class: 'screen__heading' }, this.schoolName, this.schoolSeed)),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'clockbar' },
|
||||
@@ -188,6 +190,7 @@ export class GameScreen {
|
||||
}
|
||||
|
||||
this.paintSkip();
|
||||
this.paintSeed();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
}
|
||||
@@ -195,7 +198,9 @@ export class GameScreen {
|
||||
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||
show(school: School): void {
|
||||
this.schoolId = school.id;
|
||||
this.peopleSeed = school.seed;
|
||||
this.schoolName.textContent = school.name;
|
||||
this.paintSeed();
|
||||
this.nodes = [];
|
||||
this.presence = null;
|
||||
this.directory = new Map();
|
||||
@@ -381,6 +386,10 @@ export class GameScreen {
|
||||
: '';
|
||||
}
|
||||
|
||||
private paintSeed(): void {
|
||||
this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed });
|
||||
}
|
||||
|
||||
private paintTreeLabels(): void {
|
||||
for (const [id, button] of this.treeButtons) {
|
||||
const node = this.nodes.find((candidate) => candidate.id === id);
|
||||
|
||||
@@ -55,6 +55,7 @@ internal static class SchoolEndpoints
|
||||
request.Map,
|
||||
request.NameSetId,
|
||||
request.NativeLanguage,
|
||||
request.Seed,
|
||||
NewCompletion<SchoolCreationOutcome>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
@@ -490,12 +491,13 @@ internal sealed record CreateSchoolRequest(
|
||||
IReadOnlyList<string>? ModIds,
|
||||
MapLayout? Map,
|
||||
string? NameSetId,
|
||||
string? NativeLanguage);
|
||||
string? NativeLanguage,
|
||||
int? Seed);
|
||||
|
||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
|
||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed)
|
||||
{
|
||||
public static SchoolResponse From(SchoolState school) =>
|
||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
|
||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.Seed);
|
||||
}
|
||||
|
||||
/// <summary>Everything the main menu needs in one request.</summary>
|
||||
|
||||
@@ -18,6 +18,7 @@ internal abstract record GameCommand
|
||||
MapLayout? Map,
|
||||
string? NameSetId,
|
||||
string? NativeLanguage,
|
||||
int? Seed,
|
||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||
|
||||
@@ -342,9 +342,10 @@ internal sealed class GameLoopService(
|
||||
|
||||
var id = _nextId++;
|
||||
store.WriteNextId(_nextId);
|
||||
var nativeLanguage = NativeLanguages.Pick(names, id, command.NativeLanguage, rollIfOmitted: true);
|
||||
var seed = command.Seed ?? Random.Shared.Next();
|
||||
var nativeLanguage = NativeLanguages.Pick(names, seed, command.NativeLanguage, rollIfOmitted: true);
|
||||
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage);
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage, seed);
|
||||
Track(worker);
|
||||
worker.Start();
|
||||
|
||||
@@ -366,7 +367,7 @@ internal sealed class GameLoopService(
|
||||
throw;
|
||||
}
|
||||
|
||||
logger.LogInformation("School {SchoolId} \"{Name}\" created.", id, normalized);
|
||||
logger.LogInformation("School {SchoolId} \"{Name}\" created with seed {Seed}.", id, normalized, seed);
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -513,6 +514,7 @@ internal sealed class GameLoopService(
|
||||
save.Map,
|
||||
save.NameSetId,
|
||||
save.NativeLanguage,
|
||||
createSeed: null,
|
||||
save.Presence);
|
||||
worker.Start();
|
||||
|
||||
@@ -562,6 +564,7 @@ internal sealed class GameLoopService(
|
||||
MapLayout? map,
|
||||
string? nameSetId,
|
||||
string? nativeLanguage,
|
||||
int? createSeed = null,
|
||||
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
||||
new(
|
||||
id,
|
||||
@@ -574,6 +577,7 @@ internal sealed class GameLoopService(
|
||||
map,
|
||||
nameSetId,
|
||||
nativeLanguage,
|
||||
createSeed,
|
||||
presence,
|
||||
_options,
|
||||
clients,
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace HSchool.Server.Game;
|
||||
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
|
||||
/// never leaves its worker thread.
|
||||
/// </summary>
|
||||
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
|
||||
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed);
|
||||
|
||||
/// <summary>Everything the main menu needs in one read.</summary>
|
||||
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
|
||||
|
||||
@@ -34,6 +34,7 @@ internal sealed class SchoolWorker
|
||||
private readonly MapLayout? _savedMap;
|
||||
private readonly string? _nameSetId;
|
||||
private string? _nativeLanguage;
|
||||
private readonly int? _createSeed;
|
||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
@@ -67,6 +68,7 @@ internal sealed class SchoolWorker
|
||||
MapLayout? savedMap,
|
||||
string? nameSetId,
|
||||
string? nativeLanguage,
|
||||
int? createSeed,
|
||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
@@ -86,6 +88,7 @@ internal sealed class SchoolWorker
|
||||
_savedMap = savedMap;
|
||||
_nameSetId = nameSetId;
|
||||
_nativeLanguage = nativeLanguage;
|
||||
_createSeed = createSeed;
|
||||
_savedPresence = savedPresence;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
@@ -94,7 +97,7 @@ internal sealed class SchoolWorker
|
||||
_mods = mods;
|
||||
_onFailed = onFailed;
|
||||
_logger = logger;
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, createSeed ?? 0);
|
||||
}
|
||||
|
||||
public int Id => _id;
|
||||
@@ -577,7 +580,8 @@ internal sealed class SchoolWorker
|
||||
school.Name,
|
||||
school.Clock.Time,
|
||||
school.Clock.IsRunning,
|
||||
(byte)school.Clock.SpeedIndex));
|
||||
(byte)school.Clock.SpeedIndex,
|
||||
school.PeopleSeed));
|
||||
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
||||
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
||||
@@ -856,7 +860,12 @@ internal sealed class SchoolWorker
|
||||
|
||||
if (_isNew)
|
||||
{
|
||||
seed = school.Id;
|
||||
if (_createSeed is not int createSeed)
|
||||
{
|
||||
throw new InvalidOperationException($"School {_id} was created without a people seed.");
|
||||
}
|
||||
|
||||
seed = createSeed;
|
||||
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
||||
_nativeLanguage = native;
|
||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
|
||||
|
||||
Reference in New Issue
Block a user