File size: 21,717 Bytes
84b52f0
 
 
 
 
 
 
 
 
a3f913d
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
 
 
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
 
 
 
 
 
84b52f0
 
 
 
 
 
 
a3f913d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
 
 
 
 
 
 
 
84b52f0
 
a3f913d
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
84b52f0
 
a3f913d
84b52f0
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
 
 
84b52f0
a3f913d
 
 
 
 
 
 
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3f913d
84b52f0
 
 
 
 
a3f913d
 
 
 
84b52f0
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
#include "engine/world.h"
#include "cognition/llama_bridge.h" // LlamaBridge, Cognition
#include "cognition/degradation.h"  // hearsay_hop, degrade, content_word_delta
#include "cognition/coinage.h"      // coined_terms — coinage harvest
#include "infra/db.h"               // Database::persist_memory + Step 4 logging
#include <sstream>
#include <cstdlib> // std::abs
#include <cstdio>  // snprintf
#include <iterator>
#include <fstream> // §C1 wanderer_tales() lazy-load

namespace tbv {

// Word count, used as CognitionLog's token_count (a rough proxy, not a real LLM tokenizer count
// — fine for instrumentation, this isn't fed back into anything determinism-critical).
static int word_count(const std::string& s) {
    std::istringstream iss(s);
    std::string w; int n = 0;
    while (iss >> w) n++;
    return n;
}

static std::string hex64(uint64_t h) {
    char buf[20]; snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h);
    return buf;
}

// ============================================================================
// tick() — three stages: DISPATCH (one cognition event) -> APPLY (fold back) ->
// PHYSICS (unchanged from Phase 0). Cognition only runs when a bridge is attached;
// the headless Phase 0 path is therefore byte-identical to before.
// ============================================================================
void WorldState::tick() {
    // ---- §C1: world event roll/expire. Gated `if (bridge)` -- headless Phase 0 must never
    // reach this, proven by --phase0_gate (byte-identical to the pre-C1 baseline at 6000+
    // ticks, past an EVENT_CHECK_EVERY boundary). Runs BEFORE dispatch so a fresh famine is
    // visible to this tick's build_perception() call, not one tick late. ----
    if (bridge) check_world_event();

    // ---- A. DISPATCH: exactly ONE villager does ONE cognition event (LOCK 1). ----
    if (bridge) {
        VillagerID v = (VillagerID)(current_tick % MAX_VILLAGERS);
        dispatch_cognition(v);
    }

    // ---- B. APPLY: fold back tasks due this tick, in strict total order. ----
    std::vector<DeferredTask> tasks_to_apply;
    auto ready_it = std::stable_partition(async_queue.begin(), async_queue.end(),
        [this](const DeferredTask& t) { return t.fold_tick > current_tick; });
    tasks_to_apply.assign(std::make_move_iterator(ready_it),
                          std::make_move_iterator(async_queue.end()));
    async_queue.erase(ready_it, async_queue.end());
    std::sort(tasks_to_apply.begin(), tasks_to_apply.end());
    for (const auto& task : tasks_to_apply) apply_task(task);

    // ---- C. PHYSICS: hunger decay (UNCHANGED — keeps Phase 0 hashes byte-identical). ----
    for (uint32_t i = 0; i < MAX_VILLAGERS; ++i) {
        int32_t decay = pcg32_random_bounded_r(&pcg32_physics_state[i], 50);
        // §C1 famine: double decay while active. Scales the ALREADY-DRAWN result, never
        // changes the draw itself (pcg32_random_bounded_r's bound parameter must stay fixed
        // at 50 -- rejection sampling means a different bound consumes a different, variable
        // number of underlying pcg32 calls, which WOULD desync this stream). Redundant
        // `bridge &&` here even though active_event can only ever be set inside the
        // bridge-gated check_world_event() above -- cheap, explicit, matches this file's
        // existing defense-in-depth style.
        if (bridge && active_event == VillageEvent::FAMINE) decay *= 2;
        needs[i].hunger -= decay;
        if (needs[i].hunger < 0) needs[i].hunger = 0;
    }

    current_tick++;
}

// §C1 -- ~20 hand-written lines, deterministic pick (own event RNG stream, not text
// generation). Lazy-loaded once, CWD-relative like assets/dict_en.txt.
static const std::vector<std::string>& wanderer_tales() {
    static const std::vector<std::string> tales = [] {
        std::vector<std::string> out;
        std::ifstream f("assets/wanderer_tales.txt");
        std::string line;
        while (std::getline(f, line)) if (!line.empty()) out.push_back(line);
        return out;
    }();
    return tales;
}

constexpr int32_t WANDERER_IMPORTANCE = 1800; // "above band -> gets retold immediately" (plan §C1)

// ============================================================================
// check_world_event — §C1 roll (every EVENT_CHECK_EVERY ticks) + famine expiry. Only ever
// called from tick() inside `if (bridge)`, so headless Phase 0 never reaches this function
// at all -- not just "the roll happens to not fire," the code is structurally unreachable.
// ============================================================================
void WorldState::check_world_event() {
    if (active_event == VillageEvent::FAMINE && current_tick >= event_start_tick + EVENT_DURATION) {
        active_event = VillageEvent::NONE;
    }

    if (active_event != VillageEvent::NONE || current_tick % EVENT_CHECK_EVERY != 0) return;

    uint32_t roll = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, 100);
    if (roll < 25) {
        active_event = VillageEvent::FAMINE;
        event_start_tick = current_tick;
        return;
    }
    if (roll >= 50) return; // 50-99: nothing this window

    // WANDERER (25-49): one-shot, no ongoing state. Two more draws off the SAME dedicated
    // event stream -- target villager, then which tale -- isolated the same way physics vs
    // cognition already is, so this never touches any other stream's consumption order.
    const auto& tales = wanderer_tales();
    if (tales.empty()) return; // asset missing -- fail quiet, not crash (mirrors coinage.h's
                                // fallback-empty-set behavior when its dict file is absent)
    uint32_t target = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, (int32_t)MAX_VILLAGERS);
    uint32_t idx    = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, (int32_t)tales.size());

    MemoryEntry e;
    e.mem_id        = next_mem_id++;
    e.tick          = current_tick;
    e.actor_id      = target;
    e.importance    = WANDERER_IMPORTANCE;
    e.type          = MemType::EXPERIENCE;
    e.source_depth  = 0;
    e.origin_mem_id = e.mem_id; // self-origin, same convention as a firsthand ACTION memory
    e.text          = tales[idx];
    std::optional<MemoryEntry> evicted = stores[target].add(e, current_tick);
    if (db) {
        db->persist_memory(run_id, target, e);
        db->log_belief_birth(run_id, e.mem_id, e.type, e.tick, e.importance, e.source_depth,
                             fnv64_text(e.text));
        if (evicted) db->mark_belief_death(run_id, evicted->mem_id, current_tick, evicted->origin_mem_id);
    }
}

// ============================================================================
// dispatch_cognition — pick the single event for villager v this tick and resolve
// it inline (strict sequential per Gemini [052]). Priority (R2-resolution):
//   1 survival reflex  2 hearsay(neighbour)  3 dream(lonely+cooldown)  4 action
// ============================================================================
void WorldState::dispatch_cognition(VillagerID v) {
    std::vector<VillagerID> nbrs = neighbours(v);

    Env env;
    env.holding_food = holding_food[v];
    env.food_in_reach = holding_food[v];      // no ground-food model in v1
    env.neighbour_present = !nbrs.empty();

    // Cognition seed for this event (consumes the per-villager cognition stream).
    uint32_t r = pcg32_random_r(&pcg32_cognition_state[v]);
    uint64_t cseed = splitmix64_avalanche(((uint64_t)r << 16) ^ ((uint64_t)v << 32) ^ current_tick);

    bool survival = (needs[v].hunger <= HUNGER_CRIT && holding_food[v]);

    // §B1 GROUNDED_FLOOR: every GROUNDED_FLOOR_MODth cognition turn is forced down the
    // ACTION path (25% at MOD=4), so firsthand experience isn't starved by dream/hearsay
    // priority on the dense grid. Dispatch ordinal k derives from current_tick (this
    // villager dispatches at ticks v, v+100, v+200, ... -- k = 0, 1, 2, ...), not from any
    // RNG draw, so it doesn't touch stream consumption order. Survival still overrides it.
    // (42% was considered, for Hitchhiker's sake. The towel stays packed. — Oji, 07-09)
    const uint64_t k = (current_tick - v) / MAX_VILLAGERS;
    const bool force_action = ((k % GROUNDED_FLOOR_MOD) == GROUNDED_FLOOR_MOD - 1);

    // Dream when cooldown has passed AND the villager is lonely (no neighbour) OR a 1-in-6
    // cognition roll hits — so dreams surface even on the dense grid, without starving hearsay.
    bool can_dream   = !force_action && (current_tick > last_dream_tick[v] + 100) && (nbrs.empty() || (r % 6 == 0));
    bool can_hearsay = !force_action && !nbrs.empty();

    DeferredTask t;
    t.villager_id = v;
    t.task_seq_id = task_seq_counter++;
    t.birth_tick  = current_tick;
    t.mem_id      = next_mem_id++;

    int32_t hunger_snap = needs[v].hunger, social_snap = needs[v].social, safety_snap = needs[v].safety;

    // ---- 2. DREAM (cooldown + lonely-or-roll) — checked before hearsay so it isn't starved. ----
    if (!survival && can_dream) {
        std::vector<std::string> frags;
        const auto& mems = stores[v].all();
        for (size_t k = mems.size(); k-- > 0 && frags.size() < 3; ) frags.push_back(mems[k].text);
        if (!frags.empty()) {
            t.kind          = TaskKind::DREAM;
            t.mtype         = MemType::DREAM;
            t.target        = v;
            t.origin_mem_id = t.mem_id;                    // self-origin: a dream is a new belief
            t.fold_tick     = current_tick + 20;
            t.text          = bridge->dream(v, frags, trait_prose(genomes[v]), cseed); // §B2
            t.importance    = importance(MemType::DREAM, 0, lowest_need(v), t.mem_id, v, current_tick);
            last_dream_tick[v] = current_tick;
            if (db) {
                std::string frag_blob; for (const auto& f : frags) frag_blob += f;
                db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(frag_blob)), t.text,
                                  "DREAM", word_count(t.text), genomes[v].pack(),
                                  hunger_snap, social_snap, safety_snap, cseed);
            }
            async_queue.push_back(std::move(t));
            return;
        }
        // nothing to dream about yet -> fall through
    }
    // ---- 3. HEARSAY ----
    if (!survival && can_hearsay) {
        // R1: draw always, index into the ascending-id adjacency list.
        uint32_t pick = (uint32_t)pcg32_random_bounded_r(&pcg32_cognition_state[v], (int32_t)nbrs.size());
        VillagerID src = nbrs[pick];
        const MemoryEntry* m = stores[src].most_salient(current_tick);
        if (m) {
            // §B3: bump fatigue on the memory just selected -- in-place mutation via mem_id
            // lookup, doesn't touch/invalidate `m` (see cognitive_store.h note_retold()).
            stores[src].note_retold(m->mem_id);
            // MSG_062 §2: degrade the heard memory -> two-segment retell -> forced novelty.
            // hearsay_hop is the shared engine/gate path (no logic the gate can't see).
            RetellResult rr = hearsay_hop(*bridge, v, *m, genomes[v], current_tick, cseed, &coined_words);
            t.kind          = TaskKind::HEARSAY;
            t.mtype         = MemType::HEARSAY;
            t.target        = src;                          // actor_id = source of the gossip
            t.source_depth  = (uint8_t)(m->source_depth + 1);
            t.origin_mem_id = m->origin_mem_id;             // inherit the belief-lineage root
            t.fold_tick     = current_tick + 5;
            t.text          = rr.out_text;
            t.importance    = importance(MemType::HEARSAY, t.source_depth, lowest_need(v),
                                         t.mem_id, v, current_tick);
            if (db) {
                // Re-derive the degraded text the listener actually worked from (pure function
                // of heard/tick, no RNG — safe to call again here purely for the delta metric).
                // Same coined_words set as hearsay_hop's internal degrade() call (§B6-1) --
                // otherwise this logged delta wouldn't reflect what was actually masked.
                std::string degraded = degrade(*m, current_tick, &coined_words);
                int delta = content_word_delta(degraded, rr.out_text);
                db->log_hearsay_chain(run_id, t.origin_mem_id, t.origin_mem_id, t.source_depth,
                                      src, v, genomes[src].pack(), genomes[v].pack(),
                                      m->text, rr.out_text, delta, current_tick);
                db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(degraded)), rr.out_text,
                                  "HEARSAY", word_count(rr.out_text), genomes[v].pack(),
                                  hunger_snap, social_snap, safety_snap, cseed);
            }
            async_queue.push_back(std::move(t));
            return;
        }
        // neighbour has nothing to say yet -> fall through to ACTION
    }

    // ---- 4. ACTION (default + survival path): free thought -> intent -> hybrid resolver. ----
    PerceptionContext ctx = build_perception(v, nbrs, env);
    Cognition c = bridge->infer(v, ctx, cseed);
    t.kind          = TaskKind::ACTION;
    t.mtype         = MemType::EXPERIENCE;
    t.verb          = c.verb;
    t.target        = v;
    t.origin_mem_id = t.mem_id;                             // self-origin: a firsthand experience
    if ((c.verb == Verb::GIVE || c.verb == Verb::SPEAK) && !nbrs.empty()) {
        uint32_t pick = (uint32_t)pcg32_random_bounded_r(&pcg32_cognition_state[v], (int32_t)nbrs.size());
        t.target = nbrs[pick];
    }
    t.fold_tick  = current_tick + 5;
    t.text       = c.thought;
    t.importance = importance(MemType::EXPERIENCE, 0, lowest_need(v), t.mem_id, v, current_tick);
    if (db) {
        db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(ctx.situation)), c.thought,
                          verb_name(c.verb), word_count(c.thought), genomes[v].pack(),
                          hunger_snap, social_snap, safety_snap, cseed);
    }
    async_queue.push_back(std::move(t));
}

// ============================================================================
// apply_task — physical effect (ACTION) + store the memory + persist it.
// ============================================================================
void WorldState::apply_task(const DeferredTask& t) {
    if (t.kind == TaskKind::ACTION) {
        switch (t.verb) {
            case Verb::EAT:
                if (holding_food[t.villager_id]) {
                    needs[t.villager_id].hunger =
                        std::min(FIXED_POINT_ONE, needs[t.villager_id].hunger + 40000);
                    holding_food[t.villager_id] = false;
                }
                break;
            case Verb::MOVE_TO: {
                int dir = pcg32_random_bounded_r(&pcg32_physics_state[t.villager_id], 8);
                static const int dx[8] = {-1, -1, -1,  0, 0,  1, 1, 1};
                static const int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};
                int nx = (int)pos_x[t.villager_id] + dx[dir];
                int ny = (int)pos_y[t.villager_id] + dy[dir];
                nx = std::min(std::max(nx, 0), GRID_W - 1);
                ny = std::min(std::max(ny, 0), GRID_H - 1);
                pos_x[t.villager_id] = (uint16_t)nx;
                pos_y[t.villager_id] = (uint16_t)ny;
                break;
            }
            case Verb::GIVE:
                if (holding_food[t.villager_id] && t.target != t.villager_id) {
                    holding_food[t.villager_id] = false;
                    holding_food[t.target] = true;
                    needs[t.villager_id].social = std::min(FIXED_POINT_ONE, needs[t.villager_id].social + 20000);
                    needs[t.target].social      = std::min(FIXED_POINT_ONE, needs[t.target].social + 20000);
                }
                break;
            case Verb::SPEAK:
                needs[t.villager_id].social = std::min(FIXED_POINT_ONE, needs[t.villager_id].social + 15000);
                if (t.target != t.villager_id)
                    needs[t.target].social = std::min(FIXED_POINT_ONE, needs[t.target].social + 10000);
                break;
            case Verb::WAIT:
            default: break;
        }
    }

    // Store in the actor's own store (for HEARSAY, the listener v remembers the gossip).
    MemoryEntry e;
    e.mem_id        = t.mem_id;
    e.tick          = t.birth_tick;
    e.actor_id      = (t.kind == TaskKind::HEARSAY) ? t.target : t.villager_id;
    e.importance    = t.importance;
    e.type          = t.mtype;
    e.source_depth  = t.source_depth;
    e.origin_mem_id = t.origin_mem_id;
    e.text          = t.text;
    std::optional<MemoryEntry> evicted = stores[t.villager_id].add(e, current_tick);
    if (db) {
        db->persist_memory(run_id, t.villager_id, e);
        db->log_belief_birth(run_id, e.mem_id, e.type, e.tick, e.importance, e.source_depth,
                             fnv64_text(e.text));
        if (evicted) db->mark_belief_death(run_id, evicted->mem_id, current_tick, evicted->origin_mem_id);
    }

    // ---- Coinage harvest: flag free-generation tokens absent from the system dictionary
    // AND surviving the §B5 inflection/fragment filter (coinage.h::coined_terms). First
    // coiner wins; the term then rides hearsay/dream/action text naturally. Adoption
    // (a DIFFERENT villager's text reusing an already-coined term, credited once each) feeds
    // CoinageSpread — observational for DISPATCH/RNG (still true: nothing here consumes a
    // stream draw or changes what runs next tick). §B6-2 (QA-approved, packet 102 item 4)
    // deliberately changes the other half of that old claim: `coined_words` now ALSO feeds
    // back into generated TEXT via distortion_word() (degradation.h) -- a hash-picked,
    // RNG-free read of a set that's itself a pure function of run history, so determinism
    // is unaffected, but "coinage never influences anything downstream" is no longer true.
    // That feedback loop -- a culture mutating itself via its own invented words -- IS the
    // thesis (Research Dossier §B), not a smuggled side-channel. ----
    for (const auto& term : coined_terms(t.text)) {
        if (coined_words.insert(term).second) {
            CoinageOrigin origin;
            origin.coiner = t.villager_id;
            origin.coiner_genome = genomes[t.villager_id].pack();
            origin.birth_tick = t.birth_tick;
            coinage_origin[term] = origin;
            term_adopters[term].insert(t.villager_id);
            if (db) db->register_coinage(run_id, term, t.villager_id, t.birth_tick);
        } else if (term_adopters[term].insert(t.villager_id).second && db) {
            const CoinageOrigin& origin = coinage_origin[term];
            const char* ctx = (t.kind == TaskKind::HEARSAY) ? "hearsay"
                            : (t.kind == TaskKind::DREAM)   ? "dream" : "speech";
            db->log_coinage_adoption(run_id, term, origin.coiner, origin.coiner_genome,
                                     origin.birth_tick, t.villager_id, t.birth_tick, ctx);
        }
    }
}

// ============================================================================
// neighbours — Chebyshev radius 1 (8-neighbourhood), ascending villager_id.
// ============================================================================
std::vector<VillagerID> WorldState::neighbours(VillagerID v) const {
    std::vector<VillagerID> r;
    int vx = pos_x[v], vy = pos_y[v];
    for (uint32_t i = 0; i < MAX_VILLAGERS; ++i) {
        if (i == v) continue;
        int dx = std::abs((int)pos_x[i] - vx);
        int dy = std::abs((int)pos_y[i] - vy);
        if (std::max(dx, dy) <= 1) r.push_back(i);
    }
    return r;
}

// ============================================================================
// build_perception — render real state into a grounded 2nd-person situation.
// ============================================================================
PerceptionContext WorldState::build_perception(VillagerID v,
                                               const std::vector<VillagerID>& nbrs,
                                               const Env& env) const {
    PerceptionContext ctx;
    ctx.needs = needs[v];
    ctx.env = env;

    const Genome& g = genomes[v];
    std::ostringstream s;
    s << trait_prose(g);  // §B2: single source of truth, shared with retell()/dream() now
    if (needs[v].hunger < 30000)      s << "You are very hungry. ";
    else if (needs[v].hunger < 60000) s << "You feel peckish. ";
    if (needs[v].social < 30000)      s << "You feel painfully lonely. ";
    if (needs[v].safety < 30000)      s << "You feel afraid and on edge. ";
    if (env.holding_food)             s << "You are holding some food. ";
    // §C1: only reaches ACTION-path prompts (build_perception), same scope as trait_prose
    // before B2 widened persona to retell/dream -- famine isn't threaded further here, the
    // plan only asked for this one line.
    if (active_event == VillageEvent::FAMINE) s << "A famine grips the village; food is scarce. ";
    if (!nbrs.empty()) {
        s << "Nearby stand " << nbrs.size() << " other thronglet" << (nbrs.size() > 1 ? "s" : "") << " (";
        for (size_t k = 0; k < nbrs.size(); ++k) { if (k) s << ", "; s << "villager " << nbrs[k]; }
        s << "). ";
    } else {
        s << "You are alone here. ";
    }
    s << "Think about your situation.";
    ctx.situation = s.str();
    return ctx;
}

} // namespace tbv