Phase 5: Cobblebase jobs take back gated work, dig only zone ores

From Adrian's Mining test (2026-10-31):

- A gated job assigned to a pastured Pokemon that doesn't pass its gate
  is taken back at once (BaseManager assignment cleared, Cobblebase's
  assignment sync sent, owner told what the Pokemon needs), so it no
  longer sticks or shows under Buffs -> Active.
- Mining, Ore Finder and Evolution-item Finder only give the ores and
  evolution stones of the pasture's zone: new loot condition
  cobblesurvival:in_zone, the pack's versions of the 12 Cobblebase
  tables (tools/cobblebase_loot.py), Cobblebase's built-in copies
  removed at server start (ore/BaseLoot.kt). Mining adds quartz and the
  evolution stones, drops the type gems.
- Mining's loot reaching the chest needs an Item Gatherer in the same
  pasture (Cobblebase's design); documented, not changed.
- Self-tests gated_jobs_are_taken_back, base_jobs_dig_their_zones_ores;
  docs, balance table, licenses, Phase 5 checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-31 18:56:00 +01:00
1 parent 70cc2cad50
commit 11a583db01
27 files changed
+1458 -28

No files matched your search

@@ -6,6 +6,7 @@ import com.mojang.logging.LogUtils
import dev.cobblesurvival.catching.Catching
import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.BaseLoot
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.party.ShoulderPassives
@@ -14,6 +15,7 @@ import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.world.SharedLoot
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.InZoneCondition
import dev.cobblesurvival.zone.LevelPressure
import dev.cobblesurvival.zone.Wanderers
import dev.cobblesurvival.zone.ZoneCondition
@@ -57,6 +59,7 @@ class CobbleSurvival {
init {
LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION)
MOD_BUS.addListener(::onAddPackFinders)
InZoneCondition.register(MOD_BUS)
NeoForge.EVENT_BUS.addListener(::onRegisterCommands)
NeoForge.EVENT_BUS.addListener(::onServerStarted)
NeoForge.EVENT_BUS.addListener(::onPortalSpawn)
@@ -70,6 +73,7 @@ class CobbleSurvival {
Gates.register()
ShoulderPassives.register()
OreNodes.register()
BaseLoot.register()
Catching.register()
Wanderers.register()
SharedLoot.register()
@@ -0,0 +1,52 @@
package dev.cobblesurvival.ore
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.progress.CobblebaseGate
import net.minecraft.core.BlockPos
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.entity.Entity
import net.minecraft.world.item.ItemStack
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.server.ServerAboutToStartEvent
/**
* Ores from Cobblebase's base jobs come from the pasture's zone only (Adrian, 2026-10-31). Mining, the Ore Finder and
* the Evolution-item Finder roll Cobblebase's tables `cobblebase:mining_*`, `finder_ore_*` and `finder_evo_*`.
* Cobblebase rolls its own built-in copy of a table, which knows no loot conditions, and only falls back to the data
* pack's loot table when it has none; so this takes its copies of those tables out when the server starts, and the
* pack's versions (`datapacks/core/data/cobblebase/loot_table/`, written by tools/cobblebase_loot.py with
* [dev.cobblesurvival.zone.InZoneCondition] on every ore and evolution stone) are rolled instead. A table an operator
* edits in Cobblebase's own loot editor still wins over both.
*/
object BaseLoot {
val FAMILIES = listOf("cobblebase:mining_", "cobblebase:finder_ore_", "cobblebase:finder_evo_")
private val registry by lazy { Class.forName("notlown.cobblebase.core.CobblebaseLootRegistry") }
fun register() {
NeoForge.EVENT_BUS.addListener<ServerAboutToStartEvent> { install() }
}
@Suppress("UNCHECKED_CAST")
private fun builtInTables(): MutableMap<String, Any> =
registry.getDeclaredField("tables").apply { isAccessible = true }.get(null) as MutableMap<String, Any>
fun install() {
if (!CobblebaseGate.installed) return
val tables = builtInTables()
val ours = tables.keys.filter { id -> FAMILIES.any(id::startsWith) }
ours.forEach(tables::remove)
CobbleSurvival.LOGGER.info("Cobblebase rolls the pack's zone-bound loot tables for {}", ours.sorted())
}
/** The tables of [FAMILIES] that Cobblebase would still roll from its own copy. */
fun builtInLeft(): List<String> = builtInTables().keys.filter { id -> FAMILIES.any(id::startsWith) }
/** Rolls [table] the way Cobblebase's jobs do, for a Pokémon standing at [pos]. */
@Suppress("UNCHECKED_CAST")
fun roll(table: String, level: ServerLevel, pos: BlockPos): List<ItemStack> {
val helper = Class.forName("notlown.cobblebase.core.LootHelper")
return helper.getMethod("generateLoot", String::class.java, ServerLevel::class.java, BlockPos::class.java, Entity::class.java)
.invoke(helper.getField("INSTANCE").get(null), table, level, pos, null) as List<ItemStack>
}
}
@@ -2,17 +2,25 @@ package dev.cobblesurvival.progress
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.network.protocol.common.custom.CustomPacketPayload
import net.minecraft.server.MinecraftServer
import net.neoforged.fml.ModList
import net.neoforged.neoforge.network.PacketDistributor
import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import java.util.UUID
/**
* Puts [Gates] in front of Cobblebase's skills, by reflection (Cobblebase is not a compile dependency): every skill
* executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon passes
* that skill's soft gate (level or held material); otherwise its owner is told what it needs. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One
* executor can serve several skills (all the finders), so the check is per skill, not per executor.
* that skill's soft gate (level or held material). Cobblebase looks the executor up on every tick, so the proxies take
* effect at once. One executor can serve several skills (all the finders), so the check is per skill, not per executor.
*
* A job the Pokémon is assigned to but doesn't pass the gate for is taken back at once (Adrian, 2026-10-31: a gated job
* shouldn't stick, nor show among the base's active jobs), and the owner is told what the Pokémon needs. A gated passive
* buff just stays off; its owner hears about it once ([Gates.noticeIdle]).
*/
object CobblebaseGate {
private const val CORE = "notlown.cobblebase.core"
@@ -25,6 +33,7 @@ object CobblebaseGate {
private val skillRegistry by lazy { Class.forName("$CORE.SkillRegistry").let { it to it.getField("INSTANCE").get(null) } }
internal val skillDef by lazy { Class.forName("$CORE.SkillDef") }
private val skillId by lazy { skillDef.getMethod("getId") }
private val baseManager by lazy { Class.forName("$CORE.BaseManager").let { it to it.getField("INSTANCE").get(null) } }
/** Skill id → its definition (Cobblebase's SkillDef). */
@Suppress("UNCHECKED_CAST")
@@ -58,6 +67,39 @@ object CobblebaseGate {
CobbleSurvival.LOGGER.info("Progression gates in front of {} Cobblebase skill executors", wrapped)
}
/** The job Cobblebase has the pastured Pokémon [pokemon] assigned to, or null. */
fun assignment(pokemon: UUID): String? {
val (type, instance) = baseManager
return type.getMethod("getAssignment", UUID::class.java).invoke(instance, pokemon) as String?
}
fun assign(pokemon: UUID, skill: String?) {
val (type, instance) = baseManager
type.getMethod("assignSkill", UUID::class.java, String::class.java).invoke(instance, pokemon, skill)
}
/** Takes the job [skill] back from a pastured Pokémon that doesn't pass its gate and tells its owner why. */
fun takeBack(entity: PokemonEntity, skill: String) {
assign(entity.pokemon.uuid, null)
entity.server?.let(::syncAssignments)
entity.pokemon.getOwnerPlayer()?.sendSystemMessage(Gates.jobRefused(entity.pokemon, skill))
}
/** Sends every player Cobblebase's assignments, as Cobblebase does after a change, so its screens show the new state. */
private fun syncAssignments(server: MinecraftServer) {
val (type, instance) = baseManager
val all = type.getMethod("getAllAssignments").invoke(instance) as Map<*, *>
val packet = Class.forName("$CORE.net.SkillAssignmentSyncS2CPacket").getConstructor(Map::class.java)
.newInstance(all) as CustomPacketPayload
for (player in server.playerList.players) {
try {
PacketDistributor.sendToPlayer(player, packet)
} catch (e: RuntimeException) {
CobbleSurvival.LOGGER.debug("No Cobblebase assignment sync for {}: {}", player.gameProfile.name, e.message)
}
}
}
fun isGated(executor: Any): Boolean =
Proxy.isProxyClass(executor.javaClass) && Proxy.getInvocationHandler(executor) is Gate
@@ -70,7 +112,7 @@ object CobblebaseGate {
val entity = args[2] as PokemonEntity
val skill = skillId.invoke(args[3]) as String
if (!Gates.skillOpen(entity.pokemon, skill)) {
Gates.noticeIdle(entity, skill)
if (assignment(entity.pokemon.uuid) == skill) takeBack(entity, skill) else Gates.noticeIdle(entity, skill)
return null
}
}
@@ -112,6 +112,12 @@ object Gates : SimpleJsonResourceReloadListener(Gson(), "progression_gates") {
)
}
/** "Geodude can't take the Mining job yet. It needs Lv 25, or to hold ...". */
fun jobRefused(pokemon: Pokemon, skill: String): Component = Component.translatableWithFallback(
"message.cobblesurvival.gate.job", "%s can't take the %s job yet. It needs %s.",
pokemon.getDisplayName(), CobblebaseGate.skillName(skill) ?: skill, requirement(skills[skill] ?: ""),
).withStyle(ChatFormatting.YELLOW)
/** Tells a pastured Pokémon's owner, once, why it doesn't do [skill]; only when the owner is near. */
fun noticeIdle(entity: PokemonEntity, skill: String) {
val pokemon = entity.pokemon
@@ -0,0 +1,58 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.ore.BaseLoot
import dev.cobblesurvival.zone.InZoneCondition
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.BlockPos
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.level.storage.loot.LootContext
import net.minecraft.world.level.storage.loot.LootParams
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets
import net.minecraft.world.level.storage.loot.parameters.LootContextParams
import net.minecraft.world.phys.Vec3
import java.util.Optional
/**
* Cobblebase's base jobs only dig up the pasture zone's ores (Adrian, 2026-10-31): Cobblebase's built-in copies of the
* mining and finder tables are gone, `cobblesurvival:in_zone` holds only inside its zones, and Mining's common table
* gives Button Isle coal but no iron and Westmarch iron but no coal (world/zones.json: coal is Button Isle's only ore,
* raw iron comes from Westmarch).
*/
object BaseLootTests {
private const val ROLLS = 200
fun all() = listOf(SelfTestCase("base_jobs_dig_their_zones_ores", 40, ::baseJobsDigTheirZonesOres))
/** A spot in [zone], high above the ground so no cave zone lies there. */
private fun spotIn(s: Scenario, zone: String): BlockPos {
val sample = Zones.samples[zone] ?: throw GameTestAssertException("no sample point for $zone")
val pos = BlockPos(sample.x, 250, sample.z)
s.check(Zones.at(s.level, pos)?.id == zone) { "the sample point of $zone lies in ${Zones.at(s.level, pos)?.id}" }
return pos
}
private fun baseJobsDigTheirZonesOres(s: Scenario) {
val level = s.level
s.check(BaseLoot.builtInLeft().isEmpty()) { "Cobblebase still rolls its own copies of ${BaseLoot.builtInLeft()}" }
val isle = spotIn(s, "button_isle")
val westmarch = spotIn(s, "westmarch")
val params = LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, Vec3.atCenterOf(isle)).create(LootContextParamSets.CHEST)
val context = LootContext.Builder(params).create(Optional.empty())
s.check(InZoneCondition(listOf("button_isle")).test(context)) { "in_zone [button_isle] is false on Button Isle" }
s.check(!InZoneCondition(listOf("westmarch")).test(context)) { "in_zone [westmarch] is true on Button Isle" }
fun dug(pos: BlockPos, table: String) = (1..ROLLS).flatMap { BaseLoot.roll(table, level, pos) }
.map { BuiltInRegistries.ITEM.getKey(it.item).path }.toSet()
val onIsle = dug(isle, "cobblebase:mining_common")
s.check("coal" in onIsle && "raw_iron" !in onIsle && "raw_copper" !in onIsle) { "Mining on Button Isle dug up $onIsle" }
val inWestmarch = dug(westmarch, "cobblebase:mining_common")
s.check("raw_iron" in inWestmarch && "coal" !in inWestmarch) { "Mining in Westmarch dug up $inWestmarch" }
val gems = (dug(isle, "cobblebase:mining_uncommon") + dug(isle, "cobblebase:mining_ultra_rare")).filter { it.endsWith("_gem") }
s.check(gems.isEmpty()) { "Mining still digs up type gems: $gems" }
val stones = dug(isle, "cobblebase:finder_evo_common")
s.check(stones.isEmpty()) { "the Evolution-item Finder found $stones on Button Isle, which has no evolution stones" }
s.helper.succeed()
}
}
@@ -20,7 +20,7 @@ import java.lang.reflect.Proxy
/**
* Soft gates (Adrian, 2026-10-31): Cobblebase's base skills open up through the pastured Pokémon's level or a material it
* holds, never through flags (`data/cobblesurvival/progression_gates/`). Poke Power's Power Block has no gate of its
* holds, never through flags (`data/cobblesurvival/progression_gates/`); a gated job doesn't stay assigned. Poke Power's Power Block has no gate of its
* own: its recipe already takes the Verdant Jungle's redstone.
*/
object ProgressionTests {
@@ -29,6 +29,7 @@ object ProgressionTests {
fun all() = listOf(
SelfTestCase("gates_cover_every_cobblebase_skill", 20, ::gatesCoverEverySkill),
SelfTestCase("cobblebase_skills_need_level_or_material", 20, ::skillsNeedLevelOrMaterial),
SelfTestCase("gated_jobs_are_taken_back", 20, ::gatedJobsAreTakenBack),
SelfTestCase("power_block_gated_by_its_recipe", 20, ::powerBlockGatedByRecipe),
)
@@ -50,6 +51,26 @@ object ProgressionTests {
}
/** A probe behind the same gate as Cobblebase's executors, called the way Cobblebase calls them. */
private class Probe(private val s: Scenario, private val entity: PokemonEntity) {
private var calls = 0
private val type = CobblebaseGate.executorType
private val wrapped = CobblebaseGate.wrap(
Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, _ -> if (method.name == "tick") calls++; null }
)
private val tick = type.getMethod("tick", Level::class.java, BlockPos::class.java, PokemonEntity::class.java,
CobblebaseGate.skillDef, CobblebaseGate.skillEntry)
private val entry = CobblebaseGate.skillEntry.getConstructor(String::class.java, Int::class.javaPrimitiveType)
/** Ticks [skill] for the Pokémon, holding [holding]; whether the skill ran. */
fun runs(skill: String, holding: Item? = null): Boolean {
entity.pokemon.swapHeldItem(holding?.let(::ItemStack) ?: ItemStack.EMPTY, false)
val def = CobblebaseGate.skills()[skill] ?: throw GameTestAssertException("Cobblebase has no skill $skill")
val before = calls
tick.invoke(wrapped, s.level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1))
return calls > before
}
}
private fun skillsNeedLevelOrMaterial(s: Scenario) {
val level = s.level
val player = s.player()
@@ -62,32 +83,37 @@ object ProgressionTests {
val easier = Gates.zones.values.filter { it.level < gate.level && it.items.isNotEmpty() }.minByOrNull { it.level }
val harder = Gates.zones.values.filter { it.level > gate.level && it.items.isNotEmpty() }.maxByOrNull { it.level }
var calls = 0
val type = CobblebaseGate.executorType
val probe = Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, _ -> if (method.name == "tick") calls++; null }
val wrapped = CobblebaseGate.wrap(probe)
val tick = type.getMethod("tick", Level::class.java, BlockPos::class.java, PokemonEntity::class.java,
CobblebaseGate.skillDef, CobblebaseGate.skillEntry)
val entry = CobblebaseGate.skillEntry.getConstructor(String::class.java, Int::class.javaPrimitiveType)
fun runs(skill: String, holding: Item? = null): Boolean {
pokemon.swapHeldItem(holding?.let(::ItemStack) ?: ItemStack.EMPTY, false)
val def = CobblebaseGate.skills()[skill] ?: throw GameTestAssertException("Cobblebase has no skill $skill")
val before = calls
tick.invoke(wrapped, level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1))
return calls > before
}
val probe = Probe(s, entity)
s.check(runs(openSkill)) { "$openSkill is open from the start but didn't run" }
s.check(!runs(lockedSkill)) { "$lockedSkill (gate: ${gate.zone}) ran for a Lv 1 Pokémon holding nothing" }
s.check(runs(lockedSkill, gate.items.first())) { "$lockedSkill didn't run for a Pokémon holding ${gate.items.first()} (${gate.zone})" }
s.check(probe.runs(openSkill)) { "$openSkill is open from the start but didn't run" }
s.check(!probe.runs(lockedSkill)) { "$lockedSkill (gate: ${gate.zone}) ran for a Lv 1 Pokémon holding nothing" }
s.check(probe.runs(lockedSkill, gate.items.first())) { "$lockedSkill didn't run for a Pokémon holding ${gate.items.first()} (${gate.zone})" }
if (harder != null) {
s.check(runs(lockedSkill, harder.items.first())) { "$lockedSkill didn't run with ${harder.items.first()} from the harder ${harder.zone}" }
s.check(probe.runs(lockedSkill, harder.items.first())) { "$lockedSkill didn't run with ${harder.items.first()} from the harder ${harder.zone}" }
}
if (easier != null) {
s.check(!runs(lockedSkill, easier.items.first())) { "$lockedSkill ran with ${easier.items.first()} from the easier ${easier.zone}" }
s.check(!probe.runs(lockedSkill, easier.items.first())) { "$lockedSkill ran with ${easier.items.first()} from the easier ${easier.zone}" }
}
pokemon.level = gate.level
s.check(runs(lockedSkill)) { "$lockedSkill didn't run for a Lv ${gate.level} Pokémon (${gate.zone})" }
s.check(probe.runs(lockedSkill)) { "$lockedSkill didn't run for a Lv ${gate.level} Pokémon (${gate.zone})" }
s.helper.succeed()
}
/** A gated job assigned to a Pokémon that doesn't pass is taken back at once; one it passes for stays. */
private fun gatedJobsAreTakenBack(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("pikachu", 1))
val probe = Probe(s, PokemonEntity(s.level, pokemon))
val (job, zone) = Gates.skills.entries.firstOrNull { it.value != null } ?: throw GameTestAssertException("no skill is gated")
val material = Gates.zones.getValue(zone!!).items.first()
s.afterTest { CobblebaseGate.assign(pokemon.uuid, null) }
CobblebaseGate.assign(pokemon.uuid, job)
probe.runs(job)
s.check(CobblebaseGate.assignment(pokemon.uuid) == null) { "$job stayed assigned to a Lv 1 Pokémon holding nothing (gate: $zone)" }
CobblebaseGate.assign(pokemon.uuid, job)
s.check(probe.runs(job, material)) { "$job didn't run for a Pokémon holding $material" }
s.check(CobblebaseGate.assignment(pokemon.uuid) == job) { "$job was taken back from a Pokémon holding $material" }
s.helper.succeed()
}
@@ -54,7 +54,7 @@ object SelfTest {
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() +
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all()
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
@@ -0,0 +1,44 @@
package dev.cobblesurvival.zone
import com.mojang.serialization.Codec
import com.mojang.serialization.MapCodec
import com.mojang.serialization.codecs.RecordCodecBuilder
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.BlockPos
import net.minecraft.core.registries.Registries
import net.minecraft.world.level.storage.loot.LootContext
import net.minecraft.world.level.storage.loot.parameters.LootContextParam
import net.minecraft.world.level.storage.loot.parameters.LootContextParams
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition
import net.minecraft.world.level.storage.loot.predicates.LootItemConditionType
import net.neoforged.bus.api.IEventBus
import net.neoforged.neoforge.registries.DeferredHolder
import net.neoforged.neoforge.registries.DeferredRegister
/**
* Loot condition `{"condition": "cobblesurvival:in_zone", "zones": ["westmarch", ...]}`: true when the loot's origin lies
* in one of the zones ([Zones.at]). The pack's Cobblebase mining and finder tables put it on every ore, so a pasture only
* yields its own zone's ores (Adrian, 2026-10-31; tools/cobblebase_loot.py).
*/
class InZoneCondition(val zones: List<String>) : LootItemCondition {
override fun getType(): LootItemConditionType = TYPE.get()
override fun getReferencedContextParams(): Set<LootContextParam<*>> = setOf(LootContextParams.ORIGIN)
override fun test(context: LootContext): Boolean {
val origin = context.getParamOrNull(LootContextParams.ORIGIN) ?: return false
val zone = Zones.at(context.level, BlockPos.containing(origin)) ?: return false
return zone.id in zones
}
companion object {
val CODEC: MapCodec<InZoneCondition> = RecordCodecBuilder.mapCodec { builder ->
builder.group(Codec.STRING.listOf().fieldOf("zones").forGetter(InZoneCondition::zones)).apply(builder, ::InZoneCondition)
}
private val TYPES = DeferredRegister.create(Registries.LOOT_CONDITION_TYPE, CobbleSurvival.MOD_ID)
val TYPE: DeferredHolder<LootItemConditionType, LootItemConditionType> = TYPES.register("in_zone") { -> LootItemConditionType(CODEC) }
fun register(bus: IEventBus) = TYPES.register(bus)
}
}
@@ -6,5 +6,6 @@
"message.cobblesurvival.hazard.mire": "The Mire's fumes are poisoning you. Mire Waders would keep them off.",
"message.cobblesurvival.gate.requirement": "Lv %s, or to hold %s (%s) or a material of a harder zone",
"message.cobblesurvival.gate.skill": "%s won't do %s at your base yet. It needs %s.",
"message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s."
"message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s.",
"message.cobblesurvival.gate.job": "%s can't take the %s job yet. It needs %s."
}