Kzeroko KTM2

City API

Register persistent cities, marked areas, routes and daily metadata, and resolve NPC city bindings.

Updated Sep 19, 2026 12 min read

City data belongs to a server world. CityAPI stores city bounds, local areas and routes independently of loaded NPCs; an NPC keeps only its city, residence and optional route references.

Entry point

Use net.kzeroko.isekaiexpansion.api.city.CityAPI. Its argument and return records live under internal.city; they are exposed by this facade. CityStorage and CityStructureRegistry remain internal implementation details. The event hook internal.event.events.CityEvents.CHANGED is also an internal integration point, not a separate public facade.

This is a registration and lookup service. Binding a route does not start movement, choose a schedule or set an NPC’s home position. No city data-pack loader or client synchronisation is provided by this facade.

Common tasks

Register a city, residence and route

The example uses the short city constructor, which supplies priority 0, no source structure and the generated city faction. Register the city before its children; child registration returns false if the city is absent.

static void registerDistrict(ServerWorld world) {
    Identifier id = new Identifier("example", "harbour");
    BlockPos anchor = new BlockPos(0, 64, 0);
    CityAPI.registerCity(world, new CityDefinition(
            id, anchor, new BlockBox(0, 0, 0, 31, 127, 31)));
    CityAPI.registerSubstructure(world, id, new CitySubstructureDefinition(
            "inn", CitySubstructureKind.RESIDENCE, anchor,
            new BlockBox(0, 64, 0, 7, 70, 7)));
    CityAPI.registerRoute(world, id, new CityRouteDefinition(
            "round", List.of(anchor, anchor.east(8)), true));
}

Bind an existing NPC

bindNpc checks that the area and optional route exist in the NPC’s current server world. It returns false for a null NPC, a client NPC or missing referenced data. It does not require kind RESIDENCE, test containment or change AI mode.

static boolean assignResidence(BaseNpcEntity npc) {
    CitySubstructureKey key = new CitySubstructureKey(
            new Identifier("example", "harbour"), "inn");
    return CityAPI.bindNpc(npc, key, "round");
}

Pass null to omit the route. A non-null blank or malformed route ID throws during validation. Direct NpcCityBinding construction accepts a blank optional route as absent, so its validation is not identical to bindNpc. Removing the referenced city, area or route does not clear bindings on entities; later lookups return empty for missing data.

Update daily state

Visits below is an example integration-owned integer, read as 0 when absent; it is not a built-in city field. The callback receives the live compound. Do not retain that reference or mutate it after the callback.

static boolean recordVisit(ServerWorld world, CitySubstructureKey key) {
    return CityAPI.updateDailyMetadata(world, key,
            data -> data.putInt("Visits", data.getInt("Visits") + 1));
}

A day is Math.floorDiv(world.getTime(), 24000): elapsed world time, not getTimeOfDay(). Reading returns a defensive copy or a fresh empty compound. A stale saved record is replaced only on the next successful update, not on read. A callback that changes nothing still returns true, marks dirty and emits an event. A throwing callback can leave partial mutations without the final dirty mark or event; updates are not transactional.

Reference

All world arguments are ServerWorld. Ordinary required null arguments fail through null checks; invalid local IDs throw IllegalArgumentException. Empty optionals/lists indicate absent data, not a fabricated definition.

Method (arguments)Return typeBehaviour
initBuiltinProfiles()voidRegisters the built-in profile if absent.
registerStructureProfile(structureId, profile)booleanFirst registration wins; false if this source ID already has a profile.
getStructureProfile(structureId)Optional<CityStructureProfile>Looks up the process-wide profile.
onStructurePlaced(world, structureId, bounds)booleanApplies the registered profile; false for missing profile or an existing singleton city.
registerCity(world, definition)booleanCreates or replaces the definition; retains existing children. Equal definition returns false.
registerStructureCity(world, cityId, sourceStructureId, anchorPos, bounds, factionId, priority)booleanConstructs a CityDefinition and delegates to registerCity.
removeCity(world, cityId)booleanRemoves the city, substructures, routes and daily data; false if absent.
getCity(world, cityId)Optional<CityDefinition>Looks up this world’s definition.
getCitiesAt(world, pos)List<CityDefinition>Returns all containing cities in priority order.
getCityAt(world, pos)Optional<CityDefinition>Returns the first city from getCitiesAt.
getCityFactionId(world, cityId)Optional<String>Returns the resolved faction if the city exists.
registerSubstructure(world, cityId, definition)booleanAdds or replaces by local ID; false for missing city or equal definition.
removeSubstructure(world, key)booleanAlso removes that area’s daily data; false if absent.
getSubstructure(world, key)Optional<CitySubstructureDefinition>Looks up a city/local-ID pair.
getSubstructures(world, cityId)List<CitySubstructureDefinition>Returns an insertion-ordered copy, or an empty list.
getPoiSubstructures(world, cityId, npcPoiTypeId)List<SubstructureInfo>Filters kind POI and exact npcPoiTypeId equality; does not resolve a POI registry.
getSubstructureAt(world, pos)Optional<SubstructureInfo>Selects within the first containing city that has a matching area.
registerRoute(world, cityId, definition)booleanAdds or replaces by local ID; false for missing city or equal definition.
removeRoute(world, cityId, routeId)booleanNormalises routeId and removes the route; false if absent.
getRoute(world, cityId, routeId)Optional<CityRouteDefinition>Normalises routeId and resolves the route.
getDailyMetadata(world, key)NbtCompoundReturns a defensive copy for the current time bucket, or a new empty compound.
updateDailyMetadata(world, key, updater)booleanMutates current data; false only when the city or area is missing.
bindNpc(npc, residence, routeId)booleanValidates referenced data in the NPC’s server world and sets its binding.
getNpcResidence(npc)Optional<SubstructureInfo>Resolves the current binding; null/client/unbound/unresolved gives empty.
getNpcRoute(npc)Optional<CityRouteDefinition>Resolves the optional bound route in the NPC’s current world.

SubstructureInfo contains key and definition. City/local keys use CitySubstructureKey(cityId, substructureId); the city ID is required and the local ID is normalised.

Definition contracts

Record / fieldTypeDefaultContract
CityDefinition.idIdentifierRequiredNon-null city ID, unique within a world.
CityDefinition.sourceStructureIdIdentifiernull in short constructorOptional originating structure.
CityDefinition.anchorPosBlockPosRequiredCopied to an immutable position.
CityDefinition.boundsBlockBoxRequiredInclusive three-dimensional bounds; retained by reference.
CityDefinition.factionIdStringGenerated when null/blankisekaiexpansion:city/<namespace>/<path>; nonblank input is retained.
CityDefinition.priorityint0 in short constructorAny signed integer; higher sorts first.
CitySubstructureDefinition.idStringRequiredNormalised local ID.
CitySubstructureDefinition.kindCitySubstructureKindGENERIC when nullGENERIC, RESIDENCE, VENUE, WORKPLACE, POI, PATH_NODE.
CitySubstructureDefinition.anchorPosBlockPosRequiredCopied to an immutable position.
CitySubstructureDefinition.boundsBlockBoxRequiredRetained by reference; containment in the city is not checked.
CitySubstructureDefinition.npcPoiTypeIdIdentifiernull in short constructorOptional even for kind POI; lookup compares IDs only.
CityRouteDefinition.idStringRequiredNormalised local ID.
CityRouteDefinition.nodesList<BlockPos>RequiredNonempty, no null list/elements; immutable list of immutable position copies.
CityRouteDefinition.loopsbooleanExplicit argumentStored policy for the caller; no automatic traversal.
CityStructureProfile.cityIdIdentifierRequiredFixed destination city ID.
CityStructureProfile.factionIdStringGenerated when null/blankSame generated faction as CityDefinition.
CityStructureProfile.priorityint0 in short constructorPassed to the city definition.
CityStructureProfile.singletonbooleantrue in short constructorSkip placement registration if that city already exists in this world.

Local IDs are trimmed and lowercased with Locale.ROOT, must contain 1–96 characters, and allow only a-z, 0-9, _, -, . and /. They are not namespaced identifiers. City IDs remain normal Identifier values.

The maximum indexed city footprint is 16384 chunks (MAX_CITY_INDEXED_CHUNKS), measured across inclusive X/Z chunk bounds. Oversized registration throws before replacing anything. Y extent does not affect the footprint. City queries first use the chunk index and then test full three-dimensional containment. Results sort by descending priority, ascending bounds volume, then lexical city ID.

Substructure lookup follows that city order, stops at the first city with a containing area, then chooses its smallest area by volume and local ID. It does not select the globally smallest area across overlapping cities. Routes do not validate obstacles, containment or PATH_NODE markers.

Do not mutate a retained BlockBox after registration: the stored definition and chunk index can disagree without a new registration. City replacement preserves areas, routes and daily records. Area removal deletes its daily record; route removal does not repair NPC bindings.

Structure placement

Profiles are process-wide, first-registration-wins entries. initBuiltinProfiles registers source and city ID isekaiexpansion:lucentrystel_outpost, using priority 0, generated faction and singleton true. Registering a profile alone creates no city.

The StructureStart.place return hook calls onStructurePlaced when the current chunk equals the structure bounds’ maximum X/Z chunk. It passes the whole structure bounds; the profile registration uses the minimum X/Y/Z corner as anchor. This coordinate condition is the actual hook contract, not a separate scan confirming every chunk’s completion.

With singleton: false, repeated placements still target the same profile city ID and update that definition. They do not create a distinct ID for each generated structure.

Saved state

Each dimension stores data/isekaiexpansion/city/cities_v1.dat. The root and nested fields below describe saved NBT, not an authoring JSON format. Nested Substructures, Routes and Daily belong to each city.

FieldTypeDefaultWhat it does
Citieslist<compound>emptyRoot city records; absent or wrong type loads no cities.
Cities[].IdstringrequiredValid namespaced city ID; invalid city is skipped.
Cities[].SourceStructurestringabsentOptional identifier; malformed or missing becomes null.
Cities[].AnchorlongrequiredPacked BlockPos; reader accepts numeric NBT.
Cities[].BoundscompoundrequiredBounds fields below; malformed bounds skip the city.
Cities[].FactionstringderivedBlank or missing uses isekaiexpansion:city/<namespace>/<path>.
Cities[].Priorityint0Signed overlap priority; larger wins.
Cities[].Substructureslist<compound>emptyArea records, individually validated.
Substructures[].IdstringrequiredNormalised local ID; invalid record skipped.
Substructures[].KindstringrequiredExact uppercase enum; unknown or missing skips the area.
Substructures[].AnchorlongrequiredPacked BlockPos; accepts numeric NBT.
Substructures[].BoundscompoundrequiredSame required bounds fields as a city.
Substructures[].PoiTypestringabsentOptional identifier; missing or malformed becomes null.
Cities[].Routeslist<compound>emptyOrdered-node route records.
Routes[].IdstringrequiredNormalised local ID.
Routes[].Nodeslong[]requiredNonempty long array of packed BlockPos values; missing/wrong type/empty skips route.
Routes[].LoopsbooleanfalseStored loop flag; does not itself start navigation.
Cities[].Dailylist<compound>emptyDaily metadata records.
Daily[].IdstringrequiredValid local ID of an existing substructure; otherwise skipped.
Daily[].DaylongrequiredNumeric time bucket floorDiv(world.getTime(), 24000).
Daily[].DatacompoundrequiredIntegration-owned data copied on load/save; wrong type skips record.
Bounds.MinXintrequiredCoordinate of the minimum X bound; every coordinate must be numeric.
Bounds.MinYintrequiredCoordinate of the minimum Y bound; every coordinate must be numeric.
Bounds.MinZintrequiredCoordinate of the minimum Z bound; every coordinate must be numeric.
Bounds.MaxXintrequiredCoordinate of the maximum X bound; every coordinate must be numeric.
Bounds.MaxYintrequiredCoordinate of the maximum Y bound; every coordinate must be numeric.
Bounds.MaxZintrequiredCoordinate of the maximum Z bound; every coordinate must be numeric.

Invalid records are skipped individually, including cities over the chunk footprint limit; they are not preserved as raw data for repair. Later valid duplicate city IDs replace the entire earlier city state. Later valid local IDs replace earlier records within their corresponding map. Missing child lists are empty. Daily records referring to absent substructures are discarded. There is no city-specific legacy-format reader.

The NPC’s separate CityBinding compound contains City, Residence and optional Route; its exact read rules are covered by NPC foundations. No city catalogue is copied into that entity.

Events

Subscribe to the internal CityEvents.CHANGED hook when working within the mod. The payload is Change(world, type, cityId, substructureKey, routeId). Dispatch is synchronous after a successful storage mutation, cannot be cancelled and does not catch listener exceptions.

TypeAdditional payloadTrigger
CITY_UPSERTEDNeither optional keyChanged city registration.
CITY_REMOVEDNeither optional keyExisting city removed.
SUBSTRUCTURE_UPSERTEDsubstructureKeyChanged area registration.
SUBSTRUCTURE_REMOVEDsubstructureKeyExisting area removed.
ROUTE_UPSERTEDrouteIdChanged route registration.
ROUTE_REMOVEDrouteIdExisting route removed.
DAILY_METADATA_UPDATEDsubstructureKeyEvery completed update callback.

Profile registration, NPC binding and unchanged definition registrations emit no event. A listener exception propagates after storage has already changed. Loading saved state also emits no mutation event.

Threading and lifecycle

Register structure profiles during initialisation. World queries and mutations require the relevant loaded server world and run directly on the calling thread; the facade does not schedule work onto the server thread. Use the server thread for these operations. The concurrent profile map does not make city storage or NPC mutation thread-safe.

Bound NPCs contribute the existing city’s faction through the reputation bridge. Location context collects factions from all containing cities, not only the highest-priority result. These lookups use the current world; a binding does not carry a dimension or move a city with an NPC.

Verify an integration by registering overlapping cities, querying both vertical and horizontal boundaries, replacing a city while retaining its children, and removing an area with daily data. Exercise a day change with both read-only and update calls, reload the world, and confirm stale bindings resolve empty. Check unchanged registrations and empty update callbacks separately when testing event counts.