Skip to content

Commit 8beb0da

Browse files
committed
feat: add seed signatures for entities and components
- Store per-component random seed in entity entries, accessible via entity.seed(key) for replay and debugging - Store entity-level seed derived from the generation engine, accessible via entity.seed() to reproduce all component seeds - Seed hierarchy: generation seed → entity seed → per-component seeds - Each component now gets its own seeded random engine instead of sharing one for the whole entity - Add Seed Signatures section to README with usage examples and seed hierarchy diagram - Add 9 new tests covering entity/component seed retrieval, determinism, replay, and error handling (52 total, 100% coverage)
1 parent 49a2e9b commit 8beb0da

3 files changed

Lines changed: 210 additions & 2 deletions

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ The library currently supports the following:
3030

3131
- **Reproducible Randomness**. Seed the generator for deterministic output. Supports per-call seeds and generator-level seeding for reproducible sequences.
3232

33+
- **Seed Signatures**. Every generated entity and component stores the random seed used to produce it, enabling replay and debugging.
34+
3335
- **Fluent Registration**. Chain `add()` and `remove()` calls to configure the generator.
3436

3537
- **Composable**. Components can internally use [name-generator](https://github.com/dasmig/name-generator) and [nickname-generator](https://github.com/dasmig/nickname-generator) or any other logic.
@@ -232,4 +234,39 @@ class position : public dasmig::component
232234
return L"(" + std::to_wstring(pos.x) + L", " + std::to_wstring(pos.y) + L")";
233235
}
234236
};
237+
```
238+
239+
### Seed Signatures
240+
241+
Every generated entity and component stores the random seed that produced it. This enables replay, debugging, and logging.
242+
243+
```cpp
244+
auto entity = eg::instance().generate();
245+
246+
// Retrieve the entity-level seed. This single value can reproduce
247+
// all component seeds and thus the entire entity.
248+
std::uint64_t entity_seed = entity.seed();
249+
250+
// Retrieve the seed used for a specific component.
251+
std::uint64_t age_seed = entity.seed(L"age");
252+
```
253+
254+
Component seeds can be used to replay individual component generation:
255+
256+
```cpp
257+
// Replay a component's random values using its captured seed.
258+
effolkronium::random_local rng;
259+
rng.seed(static_cast<std::mt19937::result_type>(entity.seed(L"age")));
260+
int replayed_age = rng.get(18, 65); // Same value as entity.get<int>(L"age")
261+
```
262+
263+
The seed hierarchy is fully deterministic:
264+
265+
```
266+
generation seed (per-call or generator-level)
267+
└─ entity seed ← entity.seed()
268+
└─ local engine
269+
├─ component A seed ← entity.seed(L"A")
270+
├─ component B seed ← entity.seed(L"B")
271+
└─ ...
235272
```

dasmig/entitygen.hpp

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,27 @@ class entity
135135
});
136136
}
137137

138+
// Retrieve the random seed used to generate a specific component.
139+
// This seed can be used to reproduce the component's random values.
140+
[[nodiscard]] std::uint64_t seed(const std::wstring& component_key) const
141+
{
142+
auto it = std::ranges::find(_entries, component_key, &entry::key);
143+
144+
if (it == _entries.end())
145+
{
146+
throw std::out_of_range("component not found");
147+
}
148+
149+
return it->seed;
150+
}
151+
152+
// Retrieve the random seed used to generate this entity. This seed
153+
// can reproduce all component seeds and thus the entire entity.
154+
[[nodiscard]] std::uint64_t seed() const
155+
{
156+
return _seed;
157+
}
158+
138159
// Get all component keys present in this entity, in generation order.
139160
[[nodiscard]] std::vector<std::wstring> keys() const
140161
{
@@ -167,6 +188,7 @@ class entity
167188
std::wstring key;
168189
std::any value;
169190
std::wstring display;
191+
std::uint64_t seed;
170192
};
171193

172194
// Private constructor, this is mostly a helper class to the entity
@@ -189,6 +211,9 @@ class entity
189211
// Component entries in generation order.
190212
std::vector<entry> _entries;
191213

214+
// Seed used to generate this entity.
215+
std::uint64_t _seed{0};
216+
192217
// Allows entity generator to construct entities.
193218
friend class eg;
194219
};
@@ -351,17 +376,26 @@ class eg
351376
{
352377
entity generated_entity;
353378
generation_context ctx;
354-
ctx._random.seed(engine());
379+
380+
// Derive an entity-level seed and use it to create a local engine
381+
// for per-component seed derivation.
382+
auto entity_seed = static_cast<std::uint64_t>(engine());
383+
generated_entity._seed = entity_seed;
384+
std::mt19937 local_engine{static_cast<std::mt19937::result_type>(entity_seed)};
355385

356386
for (const auto& [key, comp] : components)
357387
{
388+
auto component_seed = static_cast<std::uint64_t>(local_engine());
389+
ctx._random.seed(static_cast<std::mt19937::result_type>(component_seed));
390+
358391
auto value = comp->generate(ctx);
359392
auto display = comp->to_string(value);
360393

361394
ctx._values[key] = value;
362395
generated_entity._entries.push_back(
363396
{.key = key, .value = std::move(value),
364-
.display = std::move(display)});
397+
.display = std::move(display),
398+
.seed = component_seed});
365399
}
366400

367401
return generated_entity;

tests/tests.cpp

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,3 +913,140 @@ TEST_CASE("seeded generation reproduces dependent computed values", "[context][s
913913

914914
REQUIRE(e1.get<int>(L"scaled") == e2.get<int>(L"scaled"));
915915
}
916+
917+
// ---------------------------------------------------------------------------
918+
// Component seed signature
919+
// ---------------------------------------------------------------------------
920+
921+
TEST_CASE("entity::seed returns the seed used for a component", "[seed][signature]")
922+
{
923+
clear_generator guard;
924+
auto& gen = dasmig::eg::instance();
925+
926+
gen.add(std::make_unique<random_int_component>(L"rand", 1, 10000));
927+
928+
auto e = gen.generate(42);
929+
// Should not throw — seed exists for every generated component.
930+
REQUIRE_NOTHROW(e.seed(L"rand"));
931+
}
932+
933+
TEST_CASE("same generation seed produces same component seeds", "[seed][signature]")
934+
{
935+
clear_generator guard;
936+
auto& gen = dasmig::eg::instance();
937+
938+
gen.add(std::make_unique<random_int_component>(L"a", 1, 10000))
939+
.add(std::make_unique<random_int_component>(L"b", 1, 10000));
940+
941+
auto e1 = gen.generate(42);
942+
auto e2 = gen.generate(42);
943+
944+
REQUIRE(e1.seed(L"a") == e2.seed(L"a"));
945+
REQUIRE(e1.seed(L"b") == e2.seed(L"b"));
946+
}
947+
948+
TEST_CASE("different components get different seeds", "[seed][signature]")
949+
{
950+
clear_generator guard;
951+
auto& gen = dasmig::eg::instance();
952+
953+
gen.add(std::make_unique<random_int_component>(L"a", 1, 10000))
954+
.add(std::make_unique<random_int_component>(L"b", 1, 10000));
955+
956+
auto e = gen.generate(42);
957+
958+
// Each component should be seeded independently.
959+
REQUIRE(e.seed(L"a") != e.seed(L"b"));
960+
}
961+
962+
TEST_CASE("component seed reproduces the same value when used directly", "[seed][signature]")
963+
{
964+
clear_generator guard;
965+
auto& gen = dasmig::eg::instance();
966+
967+
gen.add(std::make_unique<random_int_component>(L"rand", 1, 10000));
968+
969+
auto e = gen.generate(42);
970+
auto captured_seed = e.seed(L"rand");
971+
int original_value = e.get<int>(L"rand");
972+
973+
// Manually seed a random engine with the captured seed and generate.
974+
effolkronium::random_local rng;
975+
rng.seed(static_cast<std::mt19937::result_type>(captured_seed));
976+
int replayed_value = rng.get(1, 10000);
977+
978+
REQUIRE(original_value == replayed_value);
979+
}
980+
981+
TEST_CASE("entity::seed throws on missing key", "[seed][signature]")
982+
{
983+
clear_generator guard;
984+
auto& gen = dasmig::eg::instance();
985+
986+
gen.add(std::make_unique<string_component>(L"a", L"hi"));
987+
auto e = gen.generate();
988+
989+
REQUIRE_THROWS_AS(e.seed(L"missing"), std::out_of_range);
990+
}
991+
992+
// ---------------------------------------------------------------------------
993+
// Entity-level seed
994+
// ---------------------------------------------------------------------------
995+
996+
TEST_CASE("entity::seed() returns the entity-level seed", "[seed][entity]")
997+
{
998+
clear_generator guard;
999+
auto& gen = dasmig::eg::instance();
1000+
1001+
gen.add(std::make_unique<random_int_component>(L"rand", 1, 10000));
1002+
1003+
auto e = gen.generate(42);
1004+
// Should not throw — every entity has a seed.
1005+
REQUIRE_NOTHROW(e.seed());
1006+
REQUIRE(e.seed() != 0);
1007+
}
1008+
1009+
TEST_CASE("same generation seed produces same entity seed", "[seed][entity]")
1010+
{
1011+
clear_generator guard;
1012+
auto& gen = dasmig::eg::instance();
1013+
1014+
gen.add(std::make_unique<random_int_component>(L"rand", 1, 10000));
1015+
1016+
auto e1 = gen.generate(42);
1017+
auto e2 = gen.generate(42);
1018+
1019+
REQUIRE(e1.seed() == e2.seed());
1020+
}
1021+
1022+
TEST_CASE("different generation seeds produce different entity seeds", "[seed][entity]")
1023+
{
1024+
clear_generator guard;
1025+
auto& gen = dasmig::eg::instance();
1026+
1027+
gen.add(std::make_unique<random_int_component>(L"rand", 1, 10000));
1028+
1029+
auto e1 = gen.generate(1);
1030+
auto e2 = gen.generate(2);
1031+
1032+
REQUIRE(e1.seed() != e2.seed());
1033+
}
1034+
1035+
TEST_CASE("entity seed reproduces all component seeds", "[seed][entity]")
1036+
{
1037+
clear_generator guard;
1038+
auto& gen = dasmig::eg::instance();
1039+
1040+
gen.add(std::make_unique<random_int_component>(L"a", 1, 10000))
1041+
.add(std::make_unique<random_int_component>(L"b", 1, 10000));
1042+
1043+
auto e = gen.generate(42);
1044+
1045+
// Derive component seeds from the entity seed the same way generate_impl does.
1046+
std::mt19937 local_engine{static_cast<std::mt19937::result_type>(e.seed())};
1047+
auto expected_seed_a = static_cast<std::uint64_t>(local_engine());
1048+
auto expected_seed_b = static_cast<std::uint64_t>(local_engine());
1049+
1050+
REQUIRE(e.seed(L"a") == expected_seed_a);
1051+
REQUIRE(e.seed(L"b") == expected_seed_b);
1052+
}

0 commit comments

Comments
 (0)