Firekeeping: shared guild fires
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c64e28fc38
commit
54ae3ae641
6 changed files with 657 additions and 34 deletions
14
README.md
14
README.md
|
|
@ -11,7 +11,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
|
||||||
|
|
||||||
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
|
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
|
||||||
jewelcrafting, construction (with masonry), cooking, leatherworking, tailoring, herblore,
|
jewelcrafting, construction (with masonry), cooking, leatherworking, tailoring, herblore,
|
||||||
combat, prayer and reconnaissance. Smiths also make hardware for builders,
|
firekeeping, combat, prayer and reconnaissance. Smiths also make hardware for builders,
|
||||||
one kind per metal (copper rivets to rune locks), which fittings and
|
one kind per metal (copper rivets to rune locks), which fittings and
|
||||||
buildings need.
|
buildings need.
|
||||||
- **Combat:** every fight is played out swing by swing: you and the enemy
|
- **Combat:** every fight is played out swing by swing: you and the enemy
|
||||||
|
|
@ -76,7 +76,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
|
||||||
- **Classes are trades,** from gathering to finished goods, so each player has
|
- **Classes are trades,** from gathering to finished goods, so each player has
|
||||||
a quiet gathering side and a busier crafting side: miner (mining, smithing,
|
a quiet gathering side and a busier crafting side: miner (mining, smithing,
|
||||||
jewelcrafting),
|
jewelcrafting),
|
||||||
builder (woodcutting, construction), homesteader (farming, cooking,
|
builder (woodcutting, construction, firekeeping), homesteader (farming, cooking,
|
||||||
herblore, tailoring) and ranger (hunting, fishing, leatherworking,
|
herblore, tailoring) and ranger (hunting, fishing, leatherworking,
|
||||||
cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make
|
cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make
|
||||||
double, and each has one bonus of its own. Homesteaders farm differently:
|
double, and each has one bonus of its own. Homesteaders farm differently:
|
||||||
|
|
@ -120,6 +120,16 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
|
||||||
potions a 10–35% chance to keep materials, Prayer more points from bones,
|
potions a 10–35% chance to keep materials, Prayer more points from bones,
|
||||||
and combat has Strength and Defence ladders. Potions last 50 uses, one per
|
and combat has Strength and Defence ladders. Potions last 50 uses, one per
|
||||||
skill at a time; an empty one is replaced from the bank automatically.
|
skill at a time; an empty one is replaced from the bank automatically.
|
||||||
|
- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen
|
||||||
|
fire (cooking), the kiln (bricks) and the still (potions). Every craft at a
|
||||||
|
fire takes one log of its tier as fuel; a fire burning that tier's flame or
|
||||||
|
hotter pays it, and a hot forge takes a coal off every bar (never the last).
|
||||||
|
Anyone can tend any fire: each go feeds one log, for minutes of fire and XP
|
||||||
|
that grow with level, and stops when the fire is full. Better logs burn
|
||||||
|
hotter colours (red, gold, white, blue, green, violet), burn first, and push
|
||||||
|
cheaper fuel out of a full fire. Whoever fed the fuel gets 10% of the XP of
|
||||||
|
every craft it pays for. Because the fires are shared, the server runs
|
||||||
|
everyone's actions as time passes, not only when they next look.
|
||||||
- **Events:** raids are announced a day or two ahead. Reconnaissance from
|
- **Events:** raids are announced a day or two ahead. Reconnaissance from
|
||||||
scouts, informants and the spymaster's guild narrows down when, how strong,
|
scouts, informants and the spymaster's guild narrows down when, how strong,
|
||||||
and finally where their camp is, so fighters can sabotage it. Fighters can
|
and finally where their camp is, so fighters can sabotage it. Fighters can
|
||||||
|
|
|
||||||
19
app.py
19
app.py
|
|
@ -87,13 +87,17 @@ class Store:
|
||||||
|
|
||||||
def tick(self) -> None:
|
def tick(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if game.tick(self.world, self.now(), self.rng):
|
now = self.now()
|
||||||
|
changed = game.tick(self.world, now, self.rng)
|
||||||
|
# Everyone's actions run as time passes: the fires are shared, so a craft
|
||||||
|
# has to meet the fire as it was, not as it is when its player next looks.
|
||||||
|
if game.catch_up(self.world, now, self.rng) or changed:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def player_view(world: dict, player: dict, now: float) -> dict:
|
def player_view(world: dict, player: dict, now: float) -> dict:
|
||||||
game.regen(world, player, now)
|
game.regen(world, player, now)
|
||||||
view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at")}
|
view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at", "away")}
|
||||||
view["hp"] = int(player["hp"])
|
view["hp"] = int(player["hp"])
|
||||||
view["max_hp"] = game.max_hp(player)
|
view["max_hp"] = game.max_hp(player)
|
||||||
view["armour"] = game.armour(player)
|
view["armour"] = game.armour(player)
|
||||||
|
|
@ -127,6 +131,10 @@ def player_view(world: dict, player: dict, now: float) -> dict:
|
||||||
view["drill_per_action"] = game.drill_per_action(player)
|
view["drill_per_action"] = game.drill_per_action(player)
|
||||||
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
|
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
|
||||||
if action.get("special") == "recon"}
|
if action.get("special") == "recon"}
|
||||||
|
level = game.skill_level(player, "firekeeping")
|
||||||
|
view["firekeeping"] = {"minutes": game.fire_minutes(level), "bank": game.fire_bank(level)}
|
||||||
|
# Inputs as the fires leave them: a lit fire pays the log.
|
||||||
|
view["fuelled"] = {action["id"]: game.fire_inputs(world, action) for action in game.ACTIONS if action.get("fire")}
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -162,6 +170,7 @@ def city_view(world: dict, now: float) -> dict:
|
||||||
"repelled": city["repelled"],
|
"repelled": city["repelled"],
|
||||||
"defence": game.defence(world),
|
"defence": game.defence(world),
|
||||||
"day": game.calendar_day(world, now) + 1,
|
"day": game.calendar_day(world, now) + 1,
|
||||||
|
"fires": game.fires_view(world, now),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -263,8 +272,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
game.tick(world, now, self.store.rng)
|
game.tick(world, now, self.store.rng)
|
||||||
player = world["players"].get(uid)
|
player = world["players"].get(uid)
|
||||||
summary = None
|
summary = None
|
||||||
|
game.catch_up(world, now, self.store.rng)
|
||||||
if player:
|
if player:
|
||||||
summary = game.process_player(world, player, now, self.store.rng)
|
summary = game.take_summary(player, game.empty_summary())
|
||||||
game.regen(world, player, now)
|
game.regen(world, player, now)
|
||||||
player["last_active"] = now
|
player["last_active"] = now
|
||||||
self.store.save()
|
self.store.save()
|
||||||
|
|
@ -294,7 +304,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
elif not player:
|
elif not player:
|
||||||
return self.fail("Create your adventurer first", HTTPStatus.CONFLICT)
|
return self.fail("Create your adventurer first", HTTPStatus.CONFLICT)
|
||||||
else:
|
else:
|
||||||
summary = game.process_player(world, player, now, self.store.rng)
|
game.catch_up(world, now, self.store.rng) # everyone, so the fires are as they really are
|
||||||
|
summary = game.take_summary(player, game.empty_summary())
|
||||||
game.regen(world, player, now)
|
game.regen(world, player, now)
|
||||||
player["last_active"] = now
|
player["last_active"] = now
|
||||||
error = self.player_route(path, player, body, now)
|
error = self.player_route(path, player, body, now)
|
||||||
|
|
|
||||||
243
game.py
243
game.py
|
|
@ -123,7 +123,8 @@ COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body":
|
||||||
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", ""),
|
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", ""),
|
||||||
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨"),
|
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨"),
|
||||||
"smithing": ("chisel", "🔧"), "jewelcrafting": ("gem drill", "🪛"), "cooking": ("cleaver", "🔪"),
|
"smithing": ("chisel", "🔧"), "jewelcrafting": ("gem drill", "🪛"), "cooking": ("cleaver", "🔪"),
|
||||||
"herblore": ("mortar and pestle", "🥣"), "leatherworking": ("awl", "📍"), "tailoring": ("shears", "✂️")}
|
"herblore": ("mortar and pestle", "🥣"), "leatherworking": ("awl", "📍"), "tailoring": ("shears", "✂️"),
|
||||||
|
"firekeeping": ("bellows", "🪭")}
|
||||||
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
|
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
|
||||||
# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast.
|
# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast.
|
||||||
# Builders make every station, from the tier's planks and hardware and a material from
|
# Builders make every station, from the tier's planks and hardware and a material from
|
||||||
|
|
@ -151,6 +152,38 @@ TREES = [
|
||||||
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
|
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
|
||||||
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
|
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
|
||||||
]
|
]
|
||||||
|
FIREWOOD = tuple(tree[0] for tree in TREES)
|
||||||
|
|
||||||
|
# The guild's fires, shared by everyone. A craft at a fire takes one log of its tier as
|
||||||
|
# fuel, and a fire burning that tier's flame or hotter pays it (the forge a coal too).
|
||||||
|
# Firekeepers feed them: fuel burns hottest first, and a better log pushes cheaper fuel
|
||||||
|
# out of a full fire. Level gives more minutes per log and a bigger bank.
|
||||||
|
FIRES = {
|
||||||
|
"forge": {"name": "Forge", "icon": "⚒️", "skill": "smithing", "glyph": "anvil-impact"},
|
||||||
|
"kitchen": {"name": "Kitchen fire", "icon": "🍳", "skill": "cooking", "glyph": "cooking-pot"},
|
||||||
|
"kiln": {"name": "Kiln", "icon": "🧱", "skill": "construction", "glyph": "brick-pile"},
|
||||||
|
"still": {"name": "Still", "icon": "⚗️", "skill": "herblore", "glyph": "erlenmeyer"},
|
||||||
|
}
|
||||||
|
FLAMES = [("Red", "#ff6a2a"), ("Gold", "#ffcb3d"), ("White", "#eeeaf8"),
|
||||||
|
("Blue", "#4fa3ff"), ("Green", "#3ed37a"), ("Violet", "#b07cff")]
|
||||||
|
TEND_XP = [2, 3, 4.5, 5.5, 6.5, 7.5] # per game minute of fire fed, by the log's tier
|
||||||
|
WARMTH = 0.1 # of a craft's XP, to whoever fed the fuel that paid for it
|
||||||
|
FIRE_LOW = 30 * 60 # game seconds left when a fire counts as low
|
||||||
|
|
||||||
|
|
||||||
|
def fire_minutes(level: int) -> float:
|
||||||
|
"""Game minutes of fire one log gives at this Firekeeping level."""
|
||||||
|
return 3 + 0.12 * level
|
||||||
|
|
||||||
|
|
||||||
|
def fire_bank(level: int) -> float:
|
||||||
|
"""The most fuel, in game minutes, a keeper of this level can bank in a fire."""
|
||||||
|
return 60 + 9 * level
|
||||||
|
|
||||||
|
|
||||||
|
def _fuelled(inputs: dict, tier: int) -> dict:
|
||||||
|
"""A fire craft's inputs: its materials and one log of its tier to burn."""
|
||||||
|
return {**inputs, FIREWOOD[tier]: inputs.get(FIREWOOD[tier], 0) + 1}
|
||||||
|
|
||||||
# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
|
# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
|
||||||
# armour id prefix, armour name prefix, leatherworking level
|
# armour id prefix, armour name prefix, leatherworking level
|
||||||
|
|
@ -452,6 +485,7 @@ SKILLS = [
|
||||||
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
|
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
|
||||||
{"id": "tailoring", "name": "Tailoring", "icon": "🧵", "group": "Crafting"},
|
{"id": "tailoring", "name": "Tailoring", "icon": "🧵", "group": "Crafting"},
|
||||||
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
|
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
|
||||||
|
{"id": "firekeeping", "name": "Firekeeping", "icon": "🔥", "group": "Crafting"},
|
||||||
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
|
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
|
||||||
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
|
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
|
||||||
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Combat"},
|
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Combat"},
|
||||||
|
|
@ -473,8 +507,8 @@ CLASSES = {
|
||||||
"combat": "Warhammer: +15% damage",
|
"combat": "Warhammer: +15% damage",
|
||||||
},
|
},
|
||||||
"builder": {
|
"builder": {
|
||||||
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction"], "craft": ["construction"],
|
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction", "firekeeping"], "craft": ["construction"],
|
||||||
"bonus": "Woodcutting and construction 20% faster",
|
"bonus": "Woodcutting, construction and firekeeping 20% faster",
|
||||||
"masterwork": "Masterwork: 10% double construction crafts",
|
"masterwork": "Masterwork: 10% double construction crafts",
|
||||||
"signature": "Foreman: +1 labour per site action",
|
"signature": "Foreman: +1 labour per site action",
|
||||||
"combat": "Sword: +15% damage",
|
"combat": "Sword: +15% damage",
|
||||||
|
|
@ -520,11 +554,10 @@ def _tiered_actions() -> list[dict]:
|
||||||
if tier == 2:
|
if tier == 2:
|
||||||
actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1}))
|
actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1}))
|
||||||
city = {"smithy": smithy} if smithy else {}
|
city = {"smithy": smithy} if smithy else {}
|
||||||
inputs = {"iron_ore" if key == "steel" else ore[0]: 1}
|
inputs = {"iron_ore" if key == "steel" else ore[0]: 1, **({"coal": coal} if coal else {})}
|
||||||
inputs.update({"coal": coal} if coal else {"logs": 1})
|
|
||||||
actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier,
|
actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier,
|
||||||
bar_xp[tier], inputs, {f"{key}_bar": 1}, group="Bars",
|
bar_xp[tier], _fuelled(inputs, tier), {f"{key}_bar": 1}, group="Bars",
|
||||||
city={"smithy": smithy} if tier >= 2 else {}))
|
city={"smithy": smithy} if tier >= 2 else {}, fire="forge", fuel=tier))
|
||||||
hardware, _name, _plural, bars, made = HARDWARE[tier]
|
hardware, _name, _plural, bars, made = HARDWARE[tier]
|
||||||
actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier,
|
actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier,
|
||||||
bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware",
|
bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware",
|
||||||
|
|
@ -576,7 +609,8 @@ def _tiered_actions() -> list[dict]:
|
||||||
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
|
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
|
||||||
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
|
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
|
||||||
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
|
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
|
||||||
[6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}, group="Fish"))
|
[6, 15, 27, 42, 58, 80][tier], _fuelled({raw[0]: 1}, tier), {cooked[0]: 1}, group="Fish",
|
||||||
|
fire="kitchen", fuel=tier))
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -934,7 +968,8 @@ ACTIONS = [
|
||||||
*[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs")
|
*[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs")
|
||||||
for herb, name, _icon, level, seconds, xp, _value in HERBS],
|
for herb, name, _icon, level, seconds, xp, _value in HERBS],
|
||||||
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
|
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
|
||||||
{herb: 1, second: 1}, {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}")
|
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
|
||||||
|
fire="still", fuel=POTION_TIER[potion])
|
||||||
for potion, name, level, herb, second, skill, _effect in POTIONS],
|
for potion, name, level, herb, second, skill, _effect in POTIONS],
|
||||||
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
|
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
|
||||||
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
|
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
|
||||||
|
|
@ -957,14 +992,17 @@ ACTIONS = [
|
||||||
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
|
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
|
||||||
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
|
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
|
||||||
for tier, (plank, name) in enumerate(PLANKS)],
|
for tier, (plank, name) in enumerate(PLANKS)],
|
||||||
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"),
|
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry",
|
||||||
|
fire="kiln", fuel=0),
|
||||||
_action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"),
|
_action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"),
|
||||||
_action("mason_beam", "construction", "Reinforced beam", 20, 45, 30,
|
_action("mason_beam", "construction", "Reinforced beam", 20, 45, 30,
|
||||||
{"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"),
|
{"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"),
|
||||||
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals"),
|
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals",
|
||||||
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals"),
|
fire="kitchen", fuel=0),
|
||||||
|
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals",
|
||||||
|
fire="kitchen", fuel=0),
|
||||||
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
|
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
|
||||||
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals"),
|
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals", fire="kitchen", fuel=0),
|
||||||
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
|
||||||
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
|
||||||
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
|
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
|
||||||
|
|
@ -980,6 +1018,10 @@ ACTIONS = [
|
||||||
devotion=20, city={"temple": 2}, group="Offer at the temple"),
|
devotion=20, city={"temple": 2}, group="Offer at the temple"),
|
||||||
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
|
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
|
||||||
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
|
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
|
||||||
|
# Tending: one log a go into one fire; better logs need more Firekeeping.
|
||||||
|
*[_action(f"tend_{fire}_{log}", "firekeeping", f"Tend the {spec['name'].lower()}", TIER_LEVELS[tier], 30, 0,
|
||||||
|
{log: 1}, special="tend", tend=fire, tier=tier, group=spec["name"])
|
||||||
|
for fire, spec in FIRES.items() for tier, log in enumerate(FIREWOOD)],
|
||||||
]
|
]
|
||||||
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
|
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
|
||||||
for _id, _gem in GEM_ROCKS.items():
|
for _id, _gem in GEM_ROCKS.items():
|
||||||
|
|
@ -1095,7 +1137,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
|
||||||
|
|
||||||
# --- world ---------------------------------------------------------------
|
# --- world ---------------------------------------------------------------
|
||||||
|
|
||||||
WORLD_VERSION = 23
|
WORLD_VERSION = 24
|
||||||
|
|
||||||
|
|
||||||
def new_world(now: float, speed: float = 1.0) -> dict:
|
def new_world(now: float, speed: float = 1.0) -> dict:
|
||||||
|
|
@ -1118,6 +1160,7 @@ def new_world(now: float, speed: float = 1.0) -> dict:
|
||||||
"food_due": 0.0,
|
"food_due": 0.0,
|
||||||
"starving": False,
|
"starving": False,
|
||||||
"repelled": 0,
|
"repelled": 0,
|
||||||
|
"fires": {key: new_fire(now) for key in FIRES},
|
||||||
},
|
},
|
||||||
"events": [],
|
"events": [],
|
||||||
"next_event_at": now + 12 * HOUR / speed,
|
"next_event_at": now + 12 * HOUR / speed,
|
||||||
|
|
@ -1273,6 +1316,15 @@ def migrate(world: dict, now: float | None = None) -> dict:
|
||||||
if item and ITEMS[item].get("station"):
|
if item and ITEMS[item].get("station"):
|
||||||
equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item
|
equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item
|
||||||
world["version"] = 23
|
world["version"] = 23
|
||||||
|
if world["version"] < 24:
|
||||||
|
# Firekeeping: a new skill, its bellows slot, and the guild's fires.
|
||||||
|
world["city"].setdefault("fires", {})
|
||||||
|
for key in FIRES:
|
||||||
|
world["city"]["fires"].setdefault(key, new_fire(now))
|
||||||
|
for player in world["players"].values():
|
||||||
|
player["xp"].setdefault("firekeeping", 0)
|
||||||
|
player["equip"].setdefault("tool_firekeeping", None)
|
||||||
|
world["version"] = 24
|
||||||
return world
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2000,6 +2052,106 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None:
|
||||||
summary["potion_out"].append(active["item"])
|
summary["potion_out"].append(active["item"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- fires -----------------------------------------------------------------
|
||||||
|
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. It burns on
|
||||||
|
# game time, hottest fuel first and the oldest of that first, so the flame's colour is
|
||||||
|
# the best fuel still in it.
|
||||||
|
|
||||||
|
def new_fire(now: float) -> dict:
|
||||||
|
return {"fuel": [], "at": now, "saved": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def burn_fires(world: dict, now: float) -> None:
|
||||||
|
"""Burn every fire down to `now`. Never runs backwards."""
|
||||||
|
for fire in world["city"]["fires"].values():
|
||||||
|
if now <= fire["at"]:
|
||||||
|
continue
|
||||||
|
seconds = (now - fire["at"]) * speed(world)
|
||||||
|
fire["at"] = now
|
||||||
|
fuel = fire["fuel"]
|
||||||
|
while seconds > 0 and fuel:
|
||||||
|
chunk = burning(fire)
|
||||||
|
used = min(seconds, chunk["left"])
|
||||||
|
chunk["left"] -= used
|
||||||
|
seconds -= used
|
||||||
|
if chunk["left"] <= 1e-6:
|
||||||
|
fuel.remove(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def burning(fire: dict) -> dict | None:
|
||||||
|
"""The fuel burning right now: the hottest, and the oldest of those."""
|
||||||
|
return max(fire["fuel"], key=lambda chunk: chunk["tier"], default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def heat(world: dict, fire: str) -> int:
|
||||||
|
"""The tier of a fire's flame, or -1 when it's out."""
|
||||||
|
chunk = burning(world["city"]["fires"][fire])
|
||||||
|
return chunk["tier"] if chunk else -1
|
||||||
|
|
||||||
|
|
||||||
|
def _room(fire: dict, tier: int, level: int) -> float:
|
||||||
|
"""Game seconds of cheaper fuel a log of `tier` would push out, or -1 if it won't fit."""
|
||||||
|
short = sum(chunk["left"] for chunk in fire["fuel"]) + fire_minutes(level) * 60 - fire_bank(level) * 60
|
||||||
|
cheaper = sum(chunk["left"] for chunk in fire["fuel"] if chunk["tier"] < tier)
|
||||||
|
return -1 if short > cheaper + 1e-6 else max(0.0, short)
|
||||||
|
|
||||||
|
|
||||||
|
def feed_fire(world: dict, player: dict, fire_id: str, tier: int) -> float:
|
||||||
|
"""Put one log in a fire, pushing out the cheapest fuel if it's full. Returns the minutes added."""
|
||||||
|
fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping")
|
||||||
|
short = _room(fire, tier, level)
|
||||||
|
while short > 1e-6:
|
||||||
|
cheapest = min((chunk for chunk in fire["fuel"] if chunk["tier"] < tier), key=lambda chunk: chunk["tier"])
|
||||||
|
out = min(short, cheapest["left"])
|
||||||
|
cheapest["left"] -= out
|
||||||
|
short -= out
|
||||||
|
if cheapest["left"] <= 1e-6:
|
||||||
|
fire["fuel"].remove(cheapest)
|
||||||
|
minutes = fire_minutes(level)
|
||||||
|
last = fire["fuel"][-1] if fire["fuel"] else None
|
||||||
|
if last and last["tier"] == tier and last["by"] == player["id"]:
|
||||||
|
last["left"] += minutes * 60
|
||||||
|
else:
|
||||||
|
fire["fuel"].append({"tier": tier, "left": minutes * 60, "by": player["id"]})
|
||||||
|
return minutes
|
||||||
|
|
||||||
|
|
||||||
|
def fire_inputs(world: dict, action: dict) -> dict:
|
||||||
|
"""What a craft takes right now: a hot enough fire pays its log, and the forge a coal (never the last)."""
|
||||||
|
fire = action.get("fire")
|
||||||
|
if not fire or heat(world, fire) < action["fuel"]:
|
||||||
|
return action["inputs"]
|
||||||
|
inputs = dict(action["inputs"])
|
||||||
|
log = FIREWOOD[action["fuel"]]
|
||||||
|
inputs[log] -= 1
|
||||||
|
if not inputs[log]:
|
||||||
|
del inputs[log]
|
||||||
|
if fire == "forge" and inputs.get("coal", 0) > 1:
|
||||||
|
inputs["coal"] -= 1
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
def _warm(world: dict, crafter: dict, action: dict, xp: float, now: float) -> None:
|
||||||
|
"""A fire paid for a craft: count it, and give its feeder a share of the XP."""
|
||||||
|
fire = world["city"]["fires"][action["fire"]]
|
||||||
|
fire["saved"] = fire.get("saved", 0) + 1
|
||||||
|
keeper = world["players"].get(burning(fire)["by"])
|
||||||
|
if keeper and keeper is not crafter:
|
||||||
|
gain_xp(world, keeper, "firekeeping", xp * WARMTH, now, keeper.setdefault("away", empty_summary()))
|
||||||
|
|
||||||
|
|
||||||
|
def fires_view(world: dict, now: float) -> dict:
|
||||||
|
burn_fires(world, now)
|
||||||
|
users = {}
|
||||||
|
for player in world["players"].values():
|
||||||
|
action = player["action"] and ACTION_BY_ID.get(player["action"]["id"])
|
||||||
|
if action and (action.get("fire") or action.get("tend")):
|
||||||
|
users.setdefault(action.get("fire") or action["tend"], []).append(
|
||||||
|
{"id": player["id"], "name": player["name"], "action": action["id"]})
|
||||||
|
return {key: {**fire, "heat": heat(world, key), "speed": speed(world), "users": users.get(key, [])}
|
||||||
|
for key, fire in world["city"]["fires"].items()}
|
||||||
|
|
||||||
|
|
||||||
def upcoming_raid(world: dict) -> dict | None:
|
def upcoming_raid(world: dict) -> dict | None:
|
||||||
"""The coming major threat: a bandit raid or a dragon."""
|
"""The coming major threat: a bandit raid or a dragon."""
|
||||||
for event in world["events"]:
|
for event in world["events"]:
|
||||||
|
|
@ -2026,12 +2178,15 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||||||
if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]:
|
if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]:
|
||||||
if not order or order["status"] != "open" or order["claimed"] != player["id"]:
|
if not order or order["status"] != "open" or order["claimed"] != player["id"]:
|
||||||
return "Work order done" if order and order["status"] == "done" else "Work order closed"
|
return "Work order done" if order and order["status"] == "done" else "Work order closed"
|
||||||
for item, qty in action["inputs"].items():
|
for item, qty in fire_inputs(world, action).items():
|
||||||
if order and item in order["provided"]:
|
if order and item in order["provided"]:
|
||||||
if order["provided"][item] < qty:
|
if order["provided"][item] < qty:
|
||||||
return f"Out of {ITEMS[item]['name'].lower()}"
|
return f"Out of {ITEMS[item]['name'].lower()}"
|
||||||
elif player["bank"].get(item, 0) < qty:
|
elif player["bank"].get(item, 0) < qty:
|
||||||
return f"Out of {ITEMS[item]['name'].lower()}"
|
return f"Out of {ITEMS[item]['name'].lower()}"
|
||||||
|
if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], action["tier"],
|
||||||
|
skill_level(player, "firekeeping")) < 0:
|
||||||
|
return f"The {FIRES[action['tend']]['name'].lower()} is full"
|
||||||
if action.get("special") == "sabotage":
|
if action.get("special") == "sabotage":
|
||||||
raid = upcoming_raid(world)
|
raid = upcoming_raid(world)
|
||||||
if not raid or intel_level(world, raid) < 3:
|
if not raid or intel_level(world, raid) < 3:
|
||||||
|
|
@ -2061,9 +2216,11 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||||||
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
|
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
|
||||||
brew = potion_effect(player, action["skill"])
|
brew = potion_effect(player, action["skill"])
|
||||||
order = working_order(world, player, action)
|
order = working_order(world, player, action)
|
||||||
own = {item: qty for item, qty in action["inputs"].items() if not order or item not in order["provided"]}
|
inputs = fire_inputs(world, action)
|
||||||
|
warmed = inputs is not action["inputs"]
|
||||||
|
own = {item: qty for item, qty in inputs.items() if not order or item not in order["provided"]}
|
||||||
if order:
|
if order:
|
||||||
for item, qty in action["inputs"].items():
|
for item, qty in inputs.items():
|
||||||
if item in order["provided"]:
|
if item in order["provided"]:
|
||||||
order["provided"][item] -= qty
|
order["provided"][item] -= qty
|
||||||
if own and rng.random() < brew.get("keep", 0):
|
if own and rng.random() < brew.get("keep", 0):
|
||||||
|
|
@ -2164,6 +2321,10 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
||||||
city["drill_boost"] = city.get("drill_boost", 0) - boost
|
city["drill_boost"] = city.get("drill_boost", 0) - boost
|
||||||
xp = DRILL_XP * (gain + boost) * mods["xp"]
|
xp = DRILL_XP * (gain + boost) * mods["xp"]
|
||||||
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
|
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
|
||||||
|
if action.get("special") == "tend":
|
||||||
|
xp = TEND_XP[action["tier"]] * feed_fire(world, player, action["tend"], action["tier"]) * mods["xp"]
|
||||||
|
if warmed:
|
||||||
|
_warm(world, player, action, xp, now)
|
||||||
if action.get("special") == "labour":
|
if action.get("special") == "labour":
|
||||||
city = world["city"]
|
city = world["city"]
|
||||||
site = city["construction"]
|
site = city["construction"]
|
||||||
|
|
@ -2249,6 +2410,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
|
||||||
duration = action_time(world, player, action)
|
duration = action_time(world, player, action)
|
||||||
if current["start"] + duration > limit:
|
if current["start"] + duration > limit:
|
||||||
break
|
break
|
||||||
|
burn_fires(world, current["start"] + duration)
|
||||||
reason = can_start(world, player, action)
|
reason = can_start(world, player, action)
|
||||||
if reason and reason.startswith("Too hurt"):
|
if reason and reason.startswith("Too hurt"):
|
||||||
if blessing(player)["wind"] and not current.get("wind_used"):
|
if blessing(player)["wind"] and not current.get("wind_used"):
|
||||||
|
|
@ -2304,6 +2466,43 @@ def stop(player: dict, reason: str | None = None) -> None:
|
||||||
player["stopped"] = reason
|
player["stopped"] = reason
|
||||||
|
|
||||||
|
|
||||||
|
def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool:
|
||||||
|
"""Run everyone's actions up to now, so the shared fires see feeds and crafts as they
|
||||||
|
happen. What each player missed waits in their `away` summary for their next visit."""
|
||||||
|
changed = False
|
||||||
|
for player in world["players"].values():
|
||||||
|
summary = process_player(world, player, now, rng)
|
||||||
|
if summary["done"] or summary["stopped"]:
|
||||||
|
merge_summary(player.setdefault("away", empty_summary()), summary)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def merge_summary(into: dict, more: dict) -> dict:
|
||||||
|
"""Fold a later summary into an earlier one."""
|
||||||
|
for key, value in more.items():
|
||||||
|
if isinstance(value, bool):
|
||||||
|
into[key] = into.get(key) or value
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
into[key] = into.get(key, 0) + value
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
for item, qty in value.items():
|
||||||
|
into.setdefault(key, {})[item] = into[key].get(item, 0) + qty
|
||||||
|
elif key == "levels":
|
||||||
|
into[key] = [entry for entry in into.get(key, []) if entry[0] not in {s for s, _ in value}] + value
|
||||||
|
elif isinstance(value, list):
|
||||||
|
into[key] = into.get(key, []) + value
|
||||||
|
elif value is not None:
|
||||||
|
into[key] = value
|
||||||
|
return into
|
||||||
|
|
||||||
|
|
||||||
|
def take_summary(player: dict, summary: dict) -> dict:
|
||||||
|
"""Everything that happened since the player last looked: the catch-up's and this request's."""
|
||||||
|
away = player.pop("away", None)
|
||||||
|
return merge_summary(away, summary) if away else summary
|
||||||
|
|
||||||
|
|
||||||
# --- work orders -----------------------------------------------------------
|
# --- work orders -----------------------------------------------------------
|
||||||
# A player asks the guild for something. A crafting order names a recipe: the
|
# A player asks the guild for something. A crafting order names a recipe: the
|
||||||
# requester can put in some of its reagents, one crafter claims it and makes it
|
# requester can put in some of its reagents, one crafter claims it and makes it
|
||||||
|
|
@ -2876,6 +3075,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
|
||||||
rng = rng or random.Random()
|
rng = rng or random.Random()
|
||||||
city = world["city"]
|
city = world["city"]
|
||||||
changed = False
|
changed = False
|
||||||
|
burn_fires(world, now)
|
||||||
step = HOUR / speed(world)
|
step = HOUR / speed(world)
|
||||||
# Never replay more than two weeks if the server was off.
|
# Never replay more than two weeks if the server was off.
|
||||||
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
|
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
|
||||||
|
|
@ -3348,7 +3548,6 @@ SEASON_WEATHER = [
|
||||||
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
|
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
|
||||||
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
|
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
|
||||||
]
|
]
|
||||||
FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs")
|
|
||||||
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
|
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
|
||||||
|
|
||||||
# Minor threats: they slow or block a skill until someone deals with them,
|
# Minor threats: they slow or block a skill until someone deals with them,
|
||||||
|
|
@ -4022,7 +4221,8 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi
|
||||||
"legs": "leg-armor", "body": "breastplate"}
|
"legs": "leg-armor", "body": "breastplate"}
|
||||||
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
|
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
|
||||||
"hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill",
|
"hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill",
|
||||||
"cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears"}
|
"cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears",
|
||||||
|
"bellows": "bellows"}
|
||||||
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
|
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
|
||||||
CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
|
CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
|
||||||
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
|
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
|
||||||
|
|
@ -4057,6 +4257,7 @@ SKILL_GLYPHS = {
|
||||||
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
|
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
|
||||||
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
|
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
|
||||||
"reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"),
|
"reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"),
|
||||||
|
"firekeeping": ("campfire", "#ff7a2a"),
|
||||||
}
|
}
|
||||||
CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
|
CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
|
||||||
"homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
|
"homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
|
||||||
|
|
@ -4098,6 +4299,9 @@ ACTION_GLYPHS.update({
|
||||||
for _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
|
for _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
|
||||||
for _race, *_rest in _races:
|
for _race, *_rest in _races:
|
||||||
ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index])
|
ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index])
|
||||||
|
for _tend in ACTIONS:
|
||||||
|
if _tend.get("special") == "tend":
|
||||||
|
ACTION_GLYPHS[_tend["id"]] = ("campfire", FLAMES[_tend["tier"]][1])
|
||||||
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds",
|
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds",
|
||||||
"archery-target", "dodging", "shield-reflect",
|
"archery-target", "dodging", "shield-reflect",
|
||||||
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "knife-fork", "round-bottom-flask", "visored-helm", "breastplate",
|
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "knife-fork", "round-bottom-flask", "visored-helm", "breastplate",
|
||||||
|
|
@ -4211,4 +4415,5 @@ def content() -> dict:
|
||||||
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
|
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
|
||||||
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
|
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
|
||||||
"regen_per_hour": REGEN_PER_HOUR,
|
"regen_per_hour": REGEN_PER_HOUR,
|
||||||
|
"fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
227
static/app.js
227
static/app.js
|
|
@ -149,7 +149,7 @@ function toast(text, kind = "") {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSummary(sum) {
|
function showSummary(sum) {
|
||||||
if (!sum.done && !sum.stopped) return;
|
if (!sum.done && !sum.stopped && !sum.levels.length) return;
|
||||||
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
|
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
|
||||||
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
|
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
|
||||||
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
|
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
|
||||||
|
|
@ -310,7 +310,11 @@ function nav(id, icon, label, meta = "", extra = "", tip = "") {
|
||||||
|
|
||||||
function skillNav(skill, me, doing) {
|
function skillNav(skill, me, doing) {
|
||||||
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
|
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
|
||||||
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
|
const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1;
|
||||||
|
const flame = heat >= 0 ? `<span class="fdot" style="--c:${flameTint(heat)}"></span>` : "";
|
||||||
|
const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `<span class="alert"></span>` : "";
|
||||||
|
const tip = heat >= 0 ? `${C.fires[fire].name}: ${C.flames[heat][0]}` : low ? "A fire is low" : "";
|
||||||
|
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, flame + busy + low, tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pinnedNav(me, doing) {
|
function pinnedNav(me, doing) {
|
||||||
|
|
@ -381,6 +385,12 @@ function topbar() {
|
||||||
} else if (S.me) {
|
} else if (S.me) {
|
||||||
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
|
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
|
||||||
}
|
}
|
||||||
|
const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : [];
|
||||||
|
if (fires.some(([, n]) => n.heat >= 0)) {
|
||||||
|
const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n");
|
||||||
|
chips += `<button class="chip fire-pips" data-nav="skill:firekeeping" title="${esc(tip)}">${fires.map(([, n]) =>
|
||||||
|
`<i class="pip ${n.heat >= 0 ? "lit" : ""} ${n.low ? "low" : ""}" style="--c:${flameTint(n.heat)}"></i>`).join("")}</button>`;
|
||||||
|
}
|
||||||
const w = S.happenings?.weather;
|
const w = S.happenings?.weather;
|
||||||
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`;
|
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`;
|
||||||
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
|
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
|
||||||
|
|
@ -391,11 +401,11 @@ function topbar() {
|
||||||
function trackedBar() {
|
function trackedBar() {
|
||||||
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
|
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
|
||||||
if (!a) return "";
|
if (!a) return "";
|
||||||
const ready = Object.entries(a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
|
const ready = Object.entries(me.fuelled?.[a.id] || a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
|
||||||
const go = !me.blocked[a.id] && me.action?.id !== a.id;
|
const go = !me.blocked[a.id] && me.action?.id !== a.id;
|
||||||
return `<div class="tracked ${ready ? "ready" : ""}">
|
return `<div class="tracked ${ready ? "ready" : ""}">
|
||||||
<button class="tracked-name" data-nav="skill:${a.skill}" data-nav-group="${esc(a.group || "")}" title="${esc(a.name)}">${a.icon}<span class="label">${esc(a.name)}</span></button>
|
<button class="tracked-name" data-nav="skill:${a.skill}" data-nav-group="${esc(a.group || "")}" title="${esc(a.name)}">${a.icon}<span class="label">${esc(a.name)}</span></button>
|
||||||
<span class="io">${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")}</span>
|
<span class="io">${inputChips(a)}</span>
|
||||||
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""}
|
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""}
|
||||||
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
|
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -430,6 +440,18 @@ function itemChip(id, qty, have) {
|
||||||
return `<span class="item-chip ${short ? "short" : ""}" title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": "))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
|
return `<span class="item-chip ${short ? "short" : ""}" title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": "))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through.
|
||||||
|
function inputChips(a) {
|
||||||
|
const me = S.me, ins = me.fuelled?.[a.id] || a.inputs;
|
||||||
|
return Object.entries(a.inputs).map(([id, q]) => {
|
||||||
|
const need = ins[id] || 0, have = me.bank[id] || 0;
|
||||||
|
if (need === q) return itemChip(id, q, have);
|
||||||
|
const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`;
|
||||||
|
return need ? `<span class="item-chip ${have < need ? "short" : ""}" ${tip}>${item(id).icon} ${fmt(have)} <span class="have">/ <s>${q}</s> ${need}</span></span>`
|
||||||
|
: `<span class="item-chip paid" ${tip}>${item(id).icon} <s>${q}</s></span>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
// Progress towards a building cost: "have / need".
|
// Progress towards a building cost: "have / need".
|
||||||
function costChip(icon, name, need, have) {
|
function costChip(icon, name, need, have) {
|
||||||
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
|
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
|
||||||
|
|
@ -459,6 +481,8 @@ function skillPage(id) {
|
||||||
: `<div class="panel muted">Nothing under construction.</div>`;
|
: `<div class="panel muted">Nothing under construction.</div>`;
|
||||||
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
|
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
|
||||||
html += skillGear(id);
|
html += skillGear(id);
|
||||||
|
if (id === "firekeeping") return html + firekeepingPage();
|
||||||
|
html += fireBand(id);
|
||||||
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
|
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
|
||||||
html += skillHappenings(id);
|
html += skillHappenings(id);
|
||||||
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
|
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
|
||||||
|
|
@ -539,6 +563,196 @@ function skillGear(skill) {
|
||||||
return cards.length ? `<div class="gear-row">${cards.join("")}</div>` : "";
|
return cards.length ? `<div class="gear-row">${cards.join("")}</div>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fires ----------------------------------------------------------------
|
||||||
|
// The guild's shared fires. Between polls the client burns them down itself, hottest
|
||||||
|
// fuel first, the same way the server does.
|
||||||
|
|
||||||
|
const ROMAN = ["I", "II", "III", "IV", "V", "VI"];
|
||||||
|
// Core to edge of each flame colour, for the animated fires.
|
||||||
|
const FLAME_RAMPS = [["#fff3cf", "#ffb54a", "#ff6a2a", "#8a210a"], ["#fffbe6", "#ffe680", "#f2a81f", "#7a4a06"],
|
||||||
|
["#ffffff", "#f6f4ff", "#c9c4e0", "#4d4a66"], ["#eef8ff", "#8fd0ff", "#3485ea", "#10347a"],
|
||||||
|
["#ecfff1", "#8af5ab", "#22b85a", "#0a4a25"], ["#f8efff", "#d6b0ff", "#9150f0", "#3a1474"]];
|
||||||
|
const STILL = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
let firewood = Number(remember("firewood") ?? -1); // the log tier picked for tending
|
||||||
|
const flames = {}; // fire -> its particles, kept across renders
|
||||||
|
let fireTextAt = 0;
|
||||||
|
|
||||||
|
const fireOfSkill = (skill) => Object.keys(C.fires).find((id) => C.fires[id].skill === skill);
|
||||||
|
const flameTint = (tier) => tier >= 0 ? C.flames[tier][1] : "#5d626b";
|
||||||
|
const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firewood[tier]}`).level;
|
||||||
|
|
||||||
|
function fireNow(id) {
|
||||||
|
const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c }));
|
||||||
|
let burn = Math.max(0, serverNow() - f.at) * f.speed;
|
||||||
|
while (burn > 0 && fuel.length) {
|
||||||
|
const top = fuel.reduce((best, c) => (c.tier > best.tier ? c : best));
|
||||||
|
const used = Math.min(burn, top.left);
|
||||||
|
top.left -= used;
|
||||||
|
burn -= used;
|
||||||
|
if (top.left <= 1e-6) fuel.splice(fuel.indexOf(top), 1);
|
||||||
|
}
|
||||||
|
const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0);
|
||||||
|
return { fuel, heat, left, low: heat >= 0 && left < C.fire_low, speed: f.speed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirewood() {
|
||||||
|
const me = S.me, open = C.firewood.map((_, t) => t).filter((t) => me.levels.firekeeping >= tendLevel(t));
|
||||||
|
if (open.includes(firewood)) return firewood;
|
||||||
|
return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firekeepingPage() {
|
||||||
|
const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed;
|
||||||
|
const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1);
|
||||||
|
const logs = C.firewood.map((log, tier) => {
|
||||||
|
const level = tendLevel(tier), locked = me.levels.firekeeping < level;
|
||||||
|
const tip = `${item(log).name} · ${C.flames[tier][0]} · ${locked ? `Needs level ${level}` : `${perLog(tier)} XP a log`}`;
|
||||||
|
return `<button class="logbtn ${tier === pick ? "on" : ""}" style="--c:${flameTint(tier)}" data-firewood="${tier}" ${locked ? "disabled" : ""} title="${esc(tip)}"><span class="swatch"></span>${item(log).icon} ${fmt(me.bank[log] || 0)}</button>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="panel fire-tools"><div class="logs">${logs}</div>
|
||||||
|
<span class="tag" title="Fire and XP per log">${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP</span>
|
||||||
|
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span></div>
|
||||||
|
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick)).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireCard(id, pick) {
|
||||||
|
const me = S.me, spec = C.fires[id], f = S.city.fires[id], n = fireNow(id), skill = skillOf(spec.skill);
|
||||||
|
const mine = me.action?.id.startsWith(`tend_${id}_`);
|
||||||
|
const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start];
|
||||||
|
const crafting = f.users.filter((u) => !u.action.startsWith("tend_"));
|
||||||
|
const doing = (u) => C.actions.find((a) => a.id === u.action);
|
||||||
|
const who = crafting.length
|
||||||
|
? `<span title="${esc(crafting.map((u) => `${u.name}: ${doing(u).name}`).join("\n"))}">👤 ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}</span>`
|
||||||
|
: `<span class="muted">👤 0</span>`;
|
||||||
|
return `<div class="fire-card ${n.heat >= 0 ? "lit" : "out"} ${mine ? "active" : ""}" style="--c:${flameTint(n.heat)}">
|
||||||
|
<div class="fire-stage"><canvas data-flame="${id}"></canvas><span class="fire-state" data-fire-state="${id}"></span></div>
|
||||||
|
<div class="fire-body">
|
||||||
|
<div class="fire-head">${glyph(spec.glyph, "#e7e9f0")}<b>${esc(spec.name)}</b><span class="muted small">${skill.icon} ${esc(skill.name)}</span></div>
|
||||||
|
<div class="fuel" data-fuel="${id}"></div>
|
||||||
|
<div class="fire-line"><span data-fire-left="${id}"></span><span data-fire-covers="${id}"></span></div>
|
||||||
|
<div class="fire-line small">${who}<span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div>
|
||||||
|
${mine ? `<div class="bar thick"><span data-progress></span></div>` : ""}
|
||||||
|
<div class="foot"><span class="why">${esc(why || "")}</span>${mine
|
||||||
|
? `<button class="btn danger" data-stop>Stop</button>`
|
||||||
|
: `<button class="btn primary" data-start="${start}" ${why ? "disabled" : ""}>Tend</button>`}</div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fire behind a crafting skill, at the top of its page.
|
||||||
|
function fireBand(skill) {
|
||||||
|
const id = fireOfSkill(skill);
|
||||||
|
if (!id) return "";
|
||||||
|
const n = fireNow(id), spec = C.fires[id], lit = n.heat >= 0;
|
||||||
|
const pays = lit && `Pays the log for tier ${ROMAN[n.heat]} and below${id === "forge" ? ", and a coal" : ""}`;
|
||||||
|
return `<div class="fire-band ${lit ? "lit" : ""}" style="--c:${flameTint(n.heat)}">
|
||||||
|
${glyph(spec.glyph, lit ? flameTint(n.heat) : "#98a1b4")}
|
||||||
|
<div class="grow"><b>${esc(spec.name)}</b> <span class="muted small">${lit ? `${C.flames[n.heat][0]} · <span data-fire-time="${id}"></span>` : "Out"}</span></div>
|
||||||
|
${lit ? `<span class="tag flame" title="${esc(pays)}">🪵 ≤ ${ROMAN[n.heat]}</span>` : ""}
|
||||||
|
<button class="btn sm" data-nav="skill:firekeeping">🔥 Tend</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateFires() {
|
||||||
|
if (!S?.me || !S.city?.fires) return;
|
||||||
|
const text = performance.now() - fireTextAt > 250;
|
||||||
|
if (text) fireTextAt = performance.now();
|
||||||
|
for (const id of Object.keys(C.fires)) {
|
||||||
|
const canvas = document.querySelector(`[data-flame="${id}"]`);
|
||||||
|
if (!canvas && !text) continue;
|
||||||
|
const n = fireNow(id);
|
||||||
|
if (canvas) drawFlame(canvas, id, n);
|
||||||
|
if (!text) continue;
|
||||||
|
const set = (sel, html) => { for (const el of document.querySelectorAll(sel)) if (el.dataset.v !== html) { el.innerHTML = html; el.dataset.v = html; } };
|
||||||
|
const bank = Math.max(S.me.firekeeping.bank * 60, n.left);
|
||||||
|
const tiers = C.flames.map((_, t) => n.fuel.filter((c) => c.tier === t).reduce((sum, c) => sum + c.left, 0));
|
||||||
|
set(`[data-fuel="${id}"]`, tiers.map((left, t) => left ? `<i style="--c:${flameTint(t)};width:${(left / bank * 100).toFixed(2)}%"></i>` : "").join(""));
|
||||||
|
set(`[data-fire-left="${id}"]`, `<b>${dur(n.left / n.speed)}</b> / ${dur(S.me.firekeeping.bank * 60 / n.speed)}`);
|
||||||
|
set(`[data-fire-time="${id}"]`, dur(n.left / n.speed));
|
||||||
|
set(`[data-fire-covers="${id}"]`, n.heat >= 0 ? `<span class="tag flame" style="--c:${flameTint(n.heat)}" title="Pays the log for tier ${ROMAN[n.heat]} and below">🪵 ≤ ${ROMAN[n.heat]}</span>` : "");
|
||||||
|
set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]);
|
||||||
|
for (const el of document.querySelectorAll(`[data-fire-state="${id}"]`)) el.className = `fire-state ${n.heat < 0 ? "out" : n.low ? "low" : ""}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rampAt(ramp, t) {
|
||||||
|
const s = t * (ramp.length - 1), i = Math.min(Math.floor(s), ramp.length - 2), f = s - i;
|
||||||
|
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16));
|
||||||
|
const a = rgb(ramp[i]), b = rgb(ramp[i + 1]);
|
||||||
|
return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(",")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fire on a canvas: rising flame particles in its colour, taller with more fuel;
|
||||||
|
// smoke when it's low, a grey bed of ash when it's out.
|
||||||
|
function drawFlame(cv, id, n) {
|
||||||
|
const st = flames[id] ||= { parts: [], at: performance.now() };
|
||||||
|
const t = performance.now(), dt = Math.min(0.05, (t - st.at) / 1000);
|
||||||
|
st.at = t;
|
||||||
|
const r = cv.getBoundingClientRect(), d = Math.min(devicePixelRatio || 1, 2);
|
||||||
|
if (!r.width) return;
|
||||||
|
if (cv.width !== Math.round(r.width * d)) { cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d); }
|
||||||
|
const ctx = cv.getContext("2d"), W = cv.width, H = cv.height, cx = W / 2, base = H - 22 * d;
|
||||||
|
const lit = n.heat >= 0, ramp = FLAME_RAMPS[Math.max(0, n.heat)];
|
||||||
|
const power = lit ? Math.min(1, 0.25 + n.left / 14400) : 0;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
if (lit && !STILL) {
|
||||||
|
for (let i = Math.round(90 * power * dt) + 1; i > 0; i--) st.parts.push({ k: "f", x: cx + (Math.random() - 0.5) * 46 * d * (0.6 + power * 0.6), y: base,
|
||||||
|
vx: (Math.random() - 0.5) * 18 * d, vy: -(40 + Math.random() * 60) * d * (0.55 + power), life: 0,
|
||||||
|
max: 0.55 + Math.random() * 0.5 * (0.5 + power), r: (7 + Math.random() * 9) * d * (0.6 + power * 0.5) });
|
||||||
|
if (Math.random() < dt * 6 * power) st.parts.push({ k: "e", x: cx + (Math.random() - 0.5) * 40 * d, y: base - 10 * d,
|
||||||
|
vx: (Math.random() - 0.5) * 30 * d, vy: -(60 + Math.random() * 60) * d, life: 0, max: 1 + Math.random(), r: 1.4 * d });
|
||||||
|
}
|
||||||
|
if (!STILL && (!lit || n.low) && Math.random() < dt * (lit ? 5 : 3)) st.parts.push({ k: "s", x: cx + (Math.random() - 0.5) * 20 * d,
|
||||||
|
y: base - (lit ? 40 * d : 0), vx: (Math.random() - 0.3) * 10 * d, vy: -(18 + Math.random() * 14) * d, life: 0, max: 2.5 + Math.random(), r: (6 + Math.random() * 5) * d });
|
||||||
|
ctx.fillStyle = "#3a2a1e";
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, base + 8 * d);
|
||||||
|
for (const [angle, w] of [[-0.18, 70], [0.2, 64], [0, 50]]) {
|
||||||
|
ctx.save(); ctx.rotate(angle); ctx.beginPath(); ctx.roundRect(-w / 2 * d, -5 * d, w * d, 10 * d, 5 * d); ctx.fill(); ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
if (lit) {
|
||||||
|
const glow = ctx.createRadialGradient(cx, base + 4 * d, 2, cx, base + 4 * d, 60 * d);
|
||||||
|
glow.addColorStop(0, ramp[1] + "aa");
|
||||||
|
glow.addColorStop(1, ramp[3] + "00");
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = "#4a4a50";
|
||||||
|
ctx.beginPath(); ctx.ellipse(cx, base + 12 * d, 38 * d, 5 * d, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
if (lit && STILL) {
|
||||||
|
const h = 44 * d * (0.6 + power * 0.5), g = ctx.createRadialGradient(cx, base - 18 * d, 2, cx, base - 18 * d, h);
|
||||||
|
ramp.forEach((c, i) => g.addColorStop(i / 3, c + (i === 3 ? "00" : "ff")));
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath(); ctx.ellipse(cx, base - 18 * d, 26 * d, h, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
st.parts = st.parts.filter((p) => (p.life += dt) < p.max);
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (const p of st.parts) {
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
const k = p.life / p.max;
|
||||||
|
if (p.k === "f") {
|
||||||
|
p.vx += (cx - p.x) * 1.8 * dt;
|
||||||
|
ctx.globalAlpha = (1 - k) * 0.55;
|
||||||
|
ctx.fillStyle = rampAt(ramp, Math.min(1, k * 1.25));
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - k * 0.7), 0, Math.PI * 2); ctx.fill();
|
||||||
|
} else if (p.k === "e") {
|
||||||
|
ctx.globalAlpha = 1 - k;
|
||||||
|
ctx.fillStyle = ramp[1];
|
||||||
|
ctx.fillRect(p.x, p.y, p.r * 2, p.r * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
for (const p of st.parts) if (p.k === "s") {
|
||||||
|
const k = p.life / p.max;
|
||||||
|
ctx.globalAlpha = (1 - k) * 0.22;
|
||||||
|
ctx.fillStyle = "#9aa0ae";
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + k * 2), 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
function prayerPanel() {
|
function prayerPanel() {
|
||||||
const me = S.me, city = S.city, level = me.levels.prayer;
|
const me = S.me, city = S.city, level = me.levels.prayer;
|
||||||
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
|
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
|
||||||
|
|
@ -773,8 +987,7 @@ function actionCard(a) {
|
||||||
const time = me.times[a.id];
|
const time = me.times[a.id];
|
||||||
let io = "";
|
let io = "";
|
||||||
if (Object.keys(a.inputs).length) {
|
if (Object.keys(a.inputs).length) {
|
||||||
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
|
io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
|
||||||
+ (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
|
|
||||||
}
|
}
|
||||||
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
|
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
|
||||||
const chance = ([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`;
|
const chance = ([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`;
|
||||||
|
|
@ -1665,6 +1878,7 @@ function animate() {
|
||||||
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
|
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
|
||||||
}
|
}
|
||||||
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
|
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
|
||||||
|
animateFires();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loop() {
|
function loop() {
|
||||||
|
|
@ -1693,6 +1907,7 @@ document.addEventListener("click", (e) => {
|
||||||
setMenu(false);
|
setMenu(false);
|
||||||
render(); window.scrollTo(0, 0); return;
|
render(); window.scrollTo(0, 0); return;
|
||||||
}
|
}
|
||||||
|
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
|
||||||
if (d.start) return act("/api/action", { id: d.start });
|
if (d.start) return act("/api/action", { id: d.start });
|
||||||
if (d.once) return act("/api/action", { id: d.once, once: true });
|
if (d.once) return act("/api/action", { id: d.once, once: true });
|
||||||
if ("stop" in d) return act("/api/stop", {});
|
if ("stop" in d) return act("/api/stop", {});
|
||||||
|
|
|
||||||
|
|
@ -632,3 +632,80 @@ button.sheet-skill:hover { background: var(--panel-2); }
|
||||||
.pick-lvl { position: absolute; bottom: -5px; right: -5px; font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); color: var(--muted); }
|
.pick-lvl { position: absolute; bottom: -5px; right: -5px; font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); color: var(--muted); }
|
||||||
.doll-slot.pick.on .pick-lvl { border-color: var(--gold); }
|
.doll-slot.pick.on .pick-lvl { border-color: var(--gold); }
|
||||||
.doll-slot.pick.on .pick-lvl { color: var(--gold); }
|
.doll-slot.pick.on .pick-lvl { color: var(--gold); }
|
||||||
|
|
||||||
|
/* --- fires ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.nav .fdot, .pip, .logbtn .swatch {
|
||||||
|
width: 9px; height: 12px; flex: none; border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
|
||||||
|
background: var(--c); box-shadow: 0 0 7px var(--c);
|
||||||
|
}
|
||||||
|
.fire-pips { gap: 5px; }
|
||||||
|
.pip { display: inline-block; background: var(--line); box-shadow: none; }
|
||||||
|
.pip.lit { background: radial-gradient(circle at 50% 75%, #fff 0 18%, var(--c) 55%); box-shadow: 0 0 8px var(--c); }
|
||||||
|
.pip.low { animation: fire-low 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes fire-low { 50% { opacity: .35; } }
|
||||||
|
.item-chip.paid { color: var(--muted); }
|
||||||
|
.item-chip s { color: var(--muted); }
|
||||||
|
.tag.flame { color: var(--c); border-color: color-mix(in srgb, var(--c) 45%, transparent); }
|
||||||
|
|
||||||
|
.fire-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.fire-tools .logs { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; }
|
||||||
|
.logbtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--line); background: var(--panel-2); font-size: 13.5px; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.logbtn .swatch { width: 8px; height: 11px; }
|
||||||
|
.logbtn .gi { width: 1.25em; height: 1.25em; }
|
||||||
|
.logbtn.on { border-color: var(--c); box-shadow: 0 0 0 1px var(--c) inset; }
|
||||||
|
.logbtn:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
|
.fire-tools .tag { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; padding: 3px 9px; }
|
||||||
|
.fire-tools .tag .gi { width: 1.2em; height: 1.2em; }
|
||||||
|
|
||||||
|
.fire-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); }
|
||||||
|
.fire-card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
|
||||||
|
display: flex; flex-direction: column; transition: border-color .4s, box-shadow .4s;
|
||||||
|
}
|
||||||
|
.fire-card.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); box-shadow: 0 10px 40px -18px var(--c); }
|
||||||
|
.fire-card.active { border-color: var(--good); box-shadow: 0 0 0 1px var(--good) inset, 0 10px 40px -18px var(--c); }
|
||||||
|
.fire-stage {
|
||||||
|
position: relative; height: 130px;
|
||||||
|
background: radial-gradient(120% 90% at 50% 100%, color-mix(in srgb, var(--c) 22%, #0e0f14) 0%, #0e0f14 70%);
|
||||||
|
}
|
||||||
|
.fire-card.out .fire-stage { background: radial-gradient(120% 90% at 50% 100%, #1b1c22 0%, #0e0f14 70%); }
|
||||||
|
.fire-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.fire-state {
|
||||||
|
position: absolute; top: 8px; right: 8px; font-size: 11.5px; font-weight: 700; padding: 1px 8px; border-radius: 99px;
|
||||||
|
background: rgba(14, 15, 20, .75); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.fire-state.low { color: var(--warn); border-color: rgba(227, 160, 67, .5); }
|
||||||
|
.fire-state.out { color: var(--muted); }
|
||||||
|
.fire-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 9px; flex: 1; }
|
||||||
|
.fire-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.fire-head .gi { width: 22px; height: 22px; flex: none; }
|
||||||
|
.fire-head b { flex: 1; }
|
||||||
|
.fuel { height: 10px; background: var(--bg); border-radius: 99px; overflow: hidden; display: flex; }
|
||||||
|
.fuel i { display: block; height: 100%; background: var(--c); }
|
||||||
|
.fire-line { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.fire-line b { color: var(--text); font-weight: 600; }
|
||||||
|
.fire-line .gi { width: 1.2em; height: 1.2em; vertical-align: -0.25em; }
|
||||||
|
.fire-body .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; }
|
||||||
|
.fire-body .why { font-size: 13px; color: var(--warn); }
|
||||||
|
|
||||||
|
.fire-band {
|
||||||
|
position: relative; overflow: hidden; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
padding: 10px 16px; margin-bottom: 14px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--panel);
|
||||||
|
}
|
||||||
|
.fire-band > .gi { width: 26px; height: 26px; flex: none; }
|
||||||
|
.fire-band.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); }
|
||||||
|
.fire-band.lit::before {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: radial-gradient(60% 140% at 0% 50%, color-mix(in srgb, var(--c) 22%, transparent), transparent 70%);
|
||||||
|
animation: fire-flicker 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.fire-band > * { position: relative; }
|
||||||
|
@keyframes fire-flicker { 0%, 100% { opacity: .95; } 30% { opacity: .7; } 55% { opacity: 1; } 75% { opacity: .8; } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pip.low, .fire-band.lit::before { animation: none; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ class TierTest(unittest.TestCase):
|
||||||
world = world_with(("kay", "miner"))
|
world = world_with(("kay", "miner"))
|
||||||
p = world["players"]["kay"]
|
p = world["players"]["kay"]
|
||||||
p["xp"]["smithing"] = game.XP_TABLE[30]
|
p["xp"]["smithing"] = game.XP_TABLE[30]
|
||||||
p["bank"] = {"iron_ore": 5, "coal": 10}
|
p["bank"] = {"iron_ore": 5, "coal": 10, "willow_logs": 5}
|
||||||
self.assertEqual(game.start_action(world, p, "smith_steel_bar", T0), "Needs Smithy level 2")
|
self.assertEqual(game.start_action(world, p, "smith_steel_bar", T0), "Needs Smithy level 2")
|
||||||
world["city"]["buildings"]["smithy"]["level"] = 2
|
world["city"]["buildings"]["smithy"]["level"] = 2
|
||||||
self.assertIsNone(game.start_action(world, p, "smith_steel_bar", T0))
|
self.assertIsNone(game.start_action(world, p, "smith_steel_bar", T0))
|
||||||
|
|
@ -903,7 +903,7 @@ class HerbloreTest(unittest.TestCase):
|
||||||
game.start_action(world, p, "grow_guam_leaf", T0)
|
game.start_action(world, p, "grow_guam_leaf", T0)
|
||||||
game.process_player(world, p, T0 + 450, random.Random(1))
|
game.process_player(world, p, T0 + 450, random.Random(1))
|
||||||
self.assertEqual(p["bank"]["guam_leaf"], 10)
|
self.assertEqual(p["bank"]["guam_leaf"], 10)
|
||||||
p["bank"]["logs"] = 3
|
p["bank"]["logs"] = 6 # one a potion, one to burn under the still
|
||||||
self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore
|
self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore
|
||||||
game.process_player(world, p, T0 + 1000, random.Random(1))
|
game.process_player(world, p, T0 + 1000, random.Random(1))
|
||||||
self.assertEqual(p["bank"]["woodcutting_potion_1"], 3)
|
self.assertEqual(p["bank"]["woodcutting_potion_1"], 3)
|
||||||
|
|
@ -1818,7 +1818,7 @@ class StationAndStorageTest(unittest.TestCase):
|
||||||
self.assertEqual(game.action_time(self.world, self.bob, labour), bare)
|
self.assertEqual(game.action_time(self.world, self.bob, labour), bare)
|
||||||
|
|
||||||
def test_stations_move_out_of_the_tool_slot(self):
|
def test_stations_move_out_of_the_tool_slot(self):
|
||||||
self.world["version"] = game.WORLD_VERSION - 1
|
self.world["version"] = 22
|
||||||
self.bob["equip"]["tool_smithing"] = "oak_anvil_stand"
|
self.bob["equip"]["tool_smithing"] = "oak_anvil_stand"
|
||||||
del self.bob["equip"]["station_smithing"]
|
del self.bob["equip"]["station_smithing"]
|
||||||
game.migrate(self.world, T0)
|
game.migrate(self.world, T0)
|
||||||
|
|
@ -1847,3 +1847,108 @@ class StationAndStorageTest(unittest.TestCase):
|
||||||
self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that")
|
self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that")
|
||||||
self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox")
|
self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox")
|
||||||
self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1)
|
self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class FirekeepingTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.world = world_with(("kai", "commoner"), ("sam", "commoner"))
|
||||||
|
self.kai, self.sam = self.world["players"]["kai"], self.world["players"]["sam"]
|
||||||
|
self.fires = self.world["city"]["fires"]
|
||||||
|
|
||||||
|
def tend(self, player, fire, log, at, seconds):
|
||||||
|
self.assertIsNone(game.start_action(self.world, player, f"tend_{fire}_{log}", at))
|
||||||
|
return game.process_player(self.world, player, at + seconds, random.Random(1))
|
||||||
|
|
||||||
|
def test_tending_feeds_only_that_fire_and_pays_by_the_minute(self):
|
||||||
|
self.kai["bank"] = {"logs": 3}
|
||||||
|
self.tend(self.kai, "forge", "logs", T0, 95)
|
||||||
|
level = game.skill_level(self.kai, "firekeeping")
|
||||||
|
self.assertEqual(self.kai["bank"].get("logs", 0), 0)
|
||||||
|
self.assertEqual(game.heat(self.world, "forge"), 0)
|
||||||
|
self.assertEqual(game.heat(self.world, "kitchen"), -1)
|
||||||
|
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 3 * game.TEND_XP[0] * game.fire_minutes(level))
|
||||||
|
|
||||||
|
def test_better_logs_need_the_level(self):
|
||||||
|
self.kai["bank"] = {"maple_logs": 3}
|
||||||
|
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_maple_logs", T0), "Needs level 45")
|
||||||
|
|
||||||
|
def test_a_full_fire_stops_tending_and_pays_nothing(self):
|
||||||
|
self.kai["bank"] = {"logs": 100}
|
||||||
|
summary = self.tend(self.kai, "forge", "logs", T0, 3000)
|
||||||
|
self.assertEqual(summary["stopped"], "The forge is full")
|
||||||
|
left = sum(chunk["left"] for chunk in self.fires["forge"]["fuel"])
|
||||||
|
self.assertGreater(left + game.fire_minutes(1) * 60, game.fire_bank(1) * 60) # no room for another log
|
||||||
|
xp = self.kai["xp"]["firekeeping"]
|
||||||
|
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_logs", T0 + 3000), "The forge is full")
|
||||||
|
self.assertEqual(self.kai["xp"]["firekeeping"], xp)
|
||||||
|
|
||||||
|
def test_a_better_log_pushes_cheaper_fuel_out_but_not_the_reverse(self):
|
||||||
|
bank = game.fire_bank(15) * 60
|
||||||
|
self.fires["kiln"].update(fuel=[{"tier": 0, "left": bank, "by": "sam"}], at=T0) # full of plain logs
|
||||||
|
self.kai["xp"]["firekeeping"] = game.XP_TABLE[15]
|
||||||
|
self.kai["bank"] = {"oak_logs": 1, "logs": 1}
|
||||||
|
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "The kiln is full")
|
||||||
|
self.assertIsNone(game.start_action(self.world, self.kai, "tend_kiln_oak_logs", T0)) # a better log still fits
|
||||||
|
game.feed_fire(self.world, self.kai, "kiln", 1)
|
||||||
|
self.assertEqual(game.heat(self.world, "kiln"), 1)
|
||||||
|
self.assertAlmostEqual(sum(chunk["left"] for chunk in self.fires["kiln"]["fuel"]), bank)
|
||||||
|
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "The kiln is full")
|
||||||
|
|
||||||
|
def test_fuel_burns_hottest_first_on_game_time(self):
|
||||||
|
fire = self.fires["still"]
|
||||||
|
fire["fuel"] = [{"tier": 0, "left": 600, "by": "sam"}, {"tier": 2, "left": 300, "by": "kai"}]
|
||||||
|
fire["at"] = T0
|
||||||
|
game.burn_fires(self.world, T0 + 200)
|
||||||
|
self.assertEqual(game.heat(self.world, "still"), 2)
|
||||||
|
game.burn_fires(self.world, T0 + 400)
|
||||||
|
self.assertEqual(game.heat(self.world, "still"), 0)
|
||||||
|
self.assertAlmostEqual(fire["fuel"][0]["left"], 500)
|
||||||
|
game.burn_fires(self.world, T0 + 100) # never backwards
|
||||||
|
self.assertEqual(fire["at"], T0 + 400)
|
||||||
|
|
||||||
|
def test_a_hot_forge_pays_the_log_and_a_coal_but_never_the_last(self):
|
||||||
|
steel, copper = game.ACTION_BY_ID["smith_steel_bar"], game.ACTION_BY_ID["smith_copper_bar"]
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1})
|
||||||
|
self.fires["forge"]["fuel"] = [{"tier": 1, "left": 600, "by": "kai"}]
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1}) # too cool
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, copper), {"copper_ore": 1})
|
||||||
|
self.fires["forge"]["fuel"] = [{"tier": 3, "left": 600, "by": "kai"}]
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 1})
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, game.ACTION_BY_ID["smith_mithril_bar"]), {"mithril_ore": 1, "coal": 2})
|
||||||
|
|
||||||
|
def test_a_potion_brewed_from_logs_keeps_its_ingredient_log(self):
|
||||||
|
brew = game.ACTION_BY_ID["brew_woodcutting_potion_1"]
|
||||||
|
self.fires["still"]["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}]
|
||||||
|
self.assertEqual(game.fire_inputs(self.world, brew), {"guam_leaf": 1, "logs": 1})
|
||||||
|
|
||||||
|
def test_warmth_goes_to_whoever_fed_the_fire(self):
|
||||||
|
self.fires["kitchen"]["fuel"] = [{"tier": 0, "left": 6000, "by": "kai"}]
|
||||||
|
self.fires["kitchen"]["at"] = T0
|
||||||
|
self.sam["bank"] = {"raw_shrimp": 4}
|
||||||
|
game.start_action(self.world, self.sam, "cook_shrimp", T0)
|
||||||
|
game.process_player(self.world, self.sam, T0 + 85, random.Random(1))
|
||||||
|
self.assertEqual(self.sam["bank"]["cooked_shrimp"], 4)
|
||||||
|
self.assertNotIn("logs", self.sam["bank"])
|
||||||
|
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
|
||||||
|
self.assertAlmostEqual(self.kai["away"]["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
|
||||||
|
self.assertEqual(self.fires["kitchen"]["saved"], 4)
|
||||||
|
self.assertEqual(self.sam["xp"]["firekeeping"], 0)
|
||||||
|
|
||||||
|
def test_catch_up_runs_everyone_and_keeps_what_they_missed(self):
|
||||||
|
game.start_action(self.world, self.sam, "mine_stone", T0)
|
||||||
|
self.assertTrue(game.catch_up(self.world, T0 + 100, random.Random(1)))
|
||||||
|
game.catch_up(self.world, T0 + 200, random.Random(1))
|
||||||
|
self.assertEqual(self.sam["away"]["done"], 10)
|
||||||
|
summary = game.take_summary(self.sam, game.process_player(self.world, self.sam, T0 + 220, random.Random(1)))
|
||||||
|
self.assertEqual(summary["done"], 11)
|
||||||
|
self.assertEqual(summary["items"], {"stone": 11})
|
||||||
|
self.assertNotIn("away", self.sam)
|
||||||
|
|
||||||
|
def test_old_worlds_get_their_fires(self):
|
||||||
|
del self.world["city"]["fires"]
|
||||||
|
del self.kai["xp"]["firekeeping"]
|
||||||
|
self.world["version"] = 23
|
||||||
|
game.migrate(self.world, T0)
|
||||||
|
self.assertEqual(set(self.world["city"]["fires"]), set(game.FIRES))
|
||||||
|
self.assertEqual(self.kai["xp"]["firekeeping"], 0)
|
||||||
|
self.assertIn("tool_firekeeping", self.kai["equip"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue