Wiki / Developer Docs / Housing System / Service Coverage

Service Coverage

How figures register services with houses, how scores decay, and how raw flags are aggregated into the values that the evolution check reads.

Pipeline Overview

[Figure walks road]
  │  figure::provide_service() called each step
  │  finds nearby houses via dcast_house()
  │  sets service flag on runtime_data (e.g. housed.school = MAX_COVERAGE)
  ▼
[End of month — city_houses.cpp]
  │  house_service_calculate_culture_aggregates()
  │    reads raw flags → writes aggregate scores
  │    (entertainment, education, health, num_gods)
  ▼
[house->evolve() called]
  │  check_requirements() reads aggregate scores
  │  compares against model thresholds
  ▼
[decay_services() called]
     decrements all service flags by 1
     (flags reach 0 = service absent next month unless renewed)

Walker Registration

Service figures inherit from figure_service_walker (or a similar base). During their movement tick they call provide_service(), which scans the tiles within range and stamps the relevant flag on each house found:

// Example: scribal school teacher (src/figure/service.cpp)
void figure_teacher::provide_service() {
    int range = 2;  // tiles
    for each tile within range of current position:
        building* b = map_building_at(tile);
        if (auto* house = b->dcast_house()) {
            house->runtime_data().school = MAX_COVERAGE;
            // MAX_COVERAGE is typically 4 — decay takes 4 months
            // to reach 0, giving walkers time to re-visit
        }
}

The flag value MAX_COVERAGE is not a boolean — it is a counter. decay_services() decrements it each month. A house covered last month still has a positive flag this month, giving the walker a grace window before the flag drops to zero and coverage is lost.

Monthly Decay

// building_house.cpp
void building_house::decay_services() {
    auto& d = runtime_data();
    // each flag: if > 0, decrement
    if (d.school)             d.school--;
    if (d.library)            d.library--;
    if (d.academy)            d.academy--;
    if (d.physician)          d.physician--;
    if (d.dentist)            d.dentist--;
    if (d.apothecary)         d.apothecary--;
    if (d.mortuary)           d.mortuary--;
    if (d.magistrate)         d.magistrate--;
    if (d.booth_juggler)      d.booth_juggler--;
    if (d.bandstand_juggler)  d.bandstand_juggler--;
    if (d.bandstand_musician) d.bandstand_musician--;
    if (d.pavillion_musician) d.pavillion_musician--;
    if (d.pavillion_dancer)   d.pavillion_dancer--;
    if (d.senet_player)       d.senet_player--;
    if (d.bullfighter)        d.bullfighter--;
    // shrine_access and bazaar_access are set by proximity checks,
    // not by walker flags — they do not decay
}

Aggregate Computation

Raw service flags are not read directly by the evolution check. Once per month, before evolve() is called, house_service_calculate_culture_aggregates() translates flags into the four aggregate scores that the model thresholds compare against.

Entertainment Score

Entertainment is a weighted sum of the four performer types. Each type contributes a share of the house's score based on a per-tier divisor from config:

// city_houses.cpp
int entertainment = 0;
if (d.booth_juggler)      entertainment += (d.booth_juggler      * 10) / model.entertainment_juggler_divider;
if (d.bandstand_juggler)  entertainment += (d.bandstand_juggler  * 10) / model.entertainment_juggler_divider;
if (d.bandstand_musician) entertainment += (d.bandstand_musician * 10) / model.entertainment_musician_divider;
if (d.pavillion_musician) entertainment += (d.pavillion_musician * 10) / model.entertainment_musician_divider;
if (d.pavillion_dancer)   entertainment += (d.pavillion_dancer   * 10) / model.entertainment_dancer_divider;
if (d.senet_player)       entertainment += (d.senet_player       * 10) / model.entertainment_senet_divider;
if (d.bullfighter)        entertainment += (d.bullfighter        * 10) / model.entertainment_dancer_divider;

d.entertainment = std::clamp(entertainment, 0, 100);

The divisors are defined per housing tier in houses.js. Higher-tier housing requires more diverse entertainment to reach the same score, meaning a single juggler booth is not sufficient past a certain point.

Education Score

Education uses a stepped model rather than a continuous score:

// city_houses.cpp
int education = 0;
if (d.school || d.library)
    education = 1;
if (d.school && d.library)
    education = 2;
if (d.school && d.library && d.academy)
    education = 3;

d.education = education;
ScoreConditionRequired for
0No school or library coverageTiers 1–10
1School OR libraryTier 11 (Common Residence)
2School AND libraryTier 17 (Elegant Manor)
3School AND library AND academyNot required by any tier in base game

Religion Score

Religion counts the number of distinct gods whose temple walkers have visited the house within the last few months:

// city_houses.cpp
int num_gods = 0;
if (d.temple_osiris) num_gods++;
if (d.temple_ra)     num_gods++;
if (d.temple_ptah)   num_gods++;
if (d.temple_seth)   num_gods++;
if (d.temple_bast)   num_gods++;

// Shrine provides minimal coverage when no temple is present
if (d.shrine_access && num_gods == 0)
    num_gods = 1;

d.num_gods = num_gods;

Each of the five gods (Osiris, Ra, Ptah, Seth, Bast) contributes independently. High-tier housing requires access to multiple gods — typically 2 gods for Fancy Residence, 3 for Elegant Manor. The player must build temples to all required gods and ensure walker paths cover the housing area.

Health Score

// city_houses.cpp
int health = 0;
if (d.apothecary) health++;
if (d.physician)  health++;
d.health = health;   // 0, 1, or 2
ScoreConditionRequired from
0No health coverage
1Apothecary or PhysicianTier 8 (Spacious Homestead)
2Both Apothecary and PhysicianNot required directly — Dentist is the gating service at higher tiers

Note: the Dentist is tracked via a separate boolean flag (d.dentist) and is checked independently from the health aggregate — it is not folded into the health score.

Water Supply

Water is not an aggregated score but a direct grid property plus a proximity check:

// Water access levels:
// 0 — no water
// 1 — well within range  (base.has_well_access)
// 2 — water supply building within range (base.has_water_access)

// Tiers 1–4 accept level 1 (well)
// Tiers 5–20 require level 2 (water supply)

The distinction between well and water supply is enforced per-tier in has_required_goods_and_services(). Placing a well next to a tier-5+ house will not satisfy the water requirement — only a proper Water Supply building qualifies.