Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
e00153826b fix: make tower game energy constraints bite (#511)
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 27s
Agent PR Gate / gate (pull_request) Failing after 1m1s
Smoke Test / smoke (pull_request) Failing after 30s
Agent PR Gate / report (pull_request) Successful in 16s
2026-04-22 11:59:36 -04:00
5 changed files with 232 additions and 263 deletions

View File

@@ -285,25 +285,24 @@ class World:
self.state.pop("phase_transition_event", None)
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
import random as _r
new_room = _r.choice(rooms)
attempts += 1
if new_room != current:
char["room"] = new_room
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
# Forge fire naturally dims if not tended
# Phase-aware: Breaking phase has higher fire-death chance
@@ -1094,7 +1093,21 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -1157,7 +1170,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1265,7 +1278,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= 0
self.world.characters["Timmy"]["energy"] -= action_cost
else:
scene["log"].append(f"{target} is not in this room.")
@@ -1302,7 +1315,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= 2
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -1324,7 +1337,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -1343,7 +1356,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -1352,7 +1365,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -1371,7 +1384,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You help {target_name}. They look grateful.")
else:
@@ -1427,6 +1440,9 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f"{char_name} says: \"{line}\"")
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()

View File

@@ -203,26 +203,25 @@ class World:
def update_world_state(self):
"""World changes independent of character actions."""
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
new_room = rooms[0] # Will change to random
attempts += 1
if new_room != current:
char["room"] = new_room
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
# Forge fire naturally dims if not tended
self.state["forge_fire_dying"] = random.random() < 0.1
# Random weather events
@@ -927,7 +926,21 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -990,7 +1003,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1092,7 +1105,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= 0
self.world.characters["Timmy"]["energy"] -= action_cost
else:
scene["log"].append(f"{target} is not in this room.")
@@ -1129,7 +1142,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= 2
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -1151,7 +1164,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -1170,7 +1183,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -1179,7 +1192,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -1198,7 +1211,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You help {target_name}. They look grateful.")
else:
@@ -1242,6 +1255,9 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f'{char_name} says: "{line}"')
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()

View File

@@ -1,206 +0,0 @@
# MATH-005 Attack Packet: √2 Continued Fraction [2;2] Pattern
**Parent:** MATH-002 Scout List — Candidate #1 (Rank S)
**Source:** OEIS A002193 comments — open question about continued fraction patterns
**Issue:** timmy-home#881
**Attack Date:** 2026-04-29
**Agent:** Timmy (sovereign first-attack)
---
## Candidate Summary (from Scout List)
> **Question:** Investigate why the [2;2] continued fraction period appears in the convergents of √2 — and whether this pattern appears with unusual frequency in "non-quadratic" approximants.
- **Source:** OEIS A002193 (comments section)
- **Domain:** Number Theory / Continued Fractions
- **Why bounded:** Computationally checkable across 10^6 convergents; requires only modular arithmetic and comparison.
- **Expected artifact:** Computational evidence note + OEIS comment / short arXiv:num-th note.
- **Verification path:** Compute convergents of √2 via recurrence, detect whether [2,2] snippet appears patterned vs. random in quadratic field approximants.
---
## Literature Search
### Known facts about √2 continued fraction
√2 has the simplest non-trivial periodic continued fraction:
```
√2 = [1; 2, 2, 2, 2, ...] (pure periodic after first term)
```
This follows from the Pell equation: if x = √2, then x satisfies x² = 2, giving the recurrence.
The convergents are:
| n | Fraction (p/q) | Decimal approximation | Error |
|---|----------------|----------------------|-------|
| 1 | 1/1 | 1.0 | 0.4142 |
| 2 | 3/2 | 1.5 | 0.0858 |
| 3 | 7/5 | 1.4 | 0.0142 |
| 4 | 17/12 | 1.416666... | 0.00245 |
| ... | ... | ... | ... |
The [2,2] snippet corresponds to: `1 + 1/(2 + 1/2) = 1 + 1/(2.5) = 7/5 = 1.4` — exactly convergent #3.
### OEIS A002193 background
A002193: Continued fraction for √2 = 1.4142... The comments section (as of 2026) contains an open question phrased:
> "Is there a reason why the [2;2] period appears with prominence in non-quadratic approximants, or is this a coincidence?"
The phrasing "non-quadratic approximants" is ambiguous. Interpretation options:
1. **Rational approximants** (the convergents themselves are degree-1, not quadratic)
2. **Approximants of non-quadratic irrationals** (e.g., π, e, √[3]{2})
### Prior work references
- Hurwitz's theorem on Diophantine approximation
- Khinchin's "Continued Fractions" (standard reference)
- OEIS entries for periodic CF patterns in √n
---
## Computational Evidence
### √2 CF extraction
First 20 CF terms for √2:
```
[1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
```
The [2,2] pattern appears at positions (1,2), (2,3), ... — continuous infinite repetition.
### Other quadratic irrationals sampled
| n | √n CF (first 12 terms) | [2,2] count |
|----|------------------------|-------------|
| 2 | [1,2,2,2,2,2,2,2,2,2,2,2] | ∞ (pure period) |
| 3 | [1,1,2,1,4,1,2,1,4,1,2,1,...] | 0 |
| 5 | [2,4,4,4,4,4,4,4,4,4,4,4,...] | 0 |
| 6 | [2,2,4,2,4,2,4,2,4,2,4,2,...] | 2 |
| 7 | [2,1,1,1,4,1,1,1,4,1,1,1,...] | 0 |
| 10 | [3,6,6,6,6,6,6,6,6,6,7,1,...] | 0 |
| 13 | [3,1,1,1,1,6,1,1,1,1,6,1,...] | 0 |
| 17 | [4,8,8,8,8,8,8,8,8,8,8,8,...] | 0 |
| 41 | [6,2,2,12,2,2,12,2,2,12,2,2,...] | 6 |
Among 43 non-square √n (n < 50), **17 contain [2,2]** at least once (~39%).
### Transcendentals and random reals sampled
| x | CF (first 12 terms) | [2,2] count |
|---|---------------------|-------------|
| π | [3,7,15,1,292,1,1,1,2,1,3,1,...] | 0 |
| e | [2,1,2,1,1,4,1,1,6,1,1,8,...] | 0 |
| φ | [1,1] (pure periodic) | 0 |
| rand(2.7) | [2,1,2,2,1,469124..., ...] | 1 |
[2,2] appears by chance in random numbers as well. Among 10 random draws in [1,5], 2 showed at least one [2,2] occurrence.
### Convergent values of interest
The snippet [2;2] as a finite CF evaluates exactly to:
```
[2;2] = 2 + 1/2 = 5/2? No — careful:
[2;2] interpreted as standalone CF = 2 + 1/2 = 2.5
But in context of √2: [1;2,2] = 1 + 1/(2 + 1/2) = 1 + 1/(2.5) = 1 + 0.4 = 1.4 = 7/5
```
So the [2,2] "snippet" means two consecutive 2s in the CF term sequence after the first term.
---
## Attempted Analysis
### Why √2 yields [2,2]
The quadratic equation x² = 2 gives the recurrence:
```
x = 1 + 1/x => x = (x+1)/x after rearranging?
Actually: x = 1 + 1/(1 + 1/x)? Let me derive properly:
√2 = 1 + (√2 - 1) = 1 + 1/(1/(√2-1)) = 1 + 1/((√2+1)/1) = 1 + 1/(√2+1)
But √2+1 ≈ 2.414, whose integer part is 2. So a₂ = 2.
Then 1/(√2+1 - 2) = 1/(√2-1) = √2+1 again — period 1 with a=2 repeated.
```
This pure period-1 of constant term 2 is special to √2 and other "silver ratios" like [n; 2n, 2n, ...].
Actually, numbers with form √(m²+1) sometimes have continued fraction [m; 2m, 2m, ...]. For √2: m=1 → [1; 2,2,2,...]. For √5: m=2 → [2;4,4,4,...]. For √10: m=3 → [3;6,6,6,...].
So [2,2] appears for √2 because it belongs to the family √(1+1) with period-1 term 2.
### Why [2,2] appears in other quadratic irrationals
Examining √6: CF = [2;2,4,2,4,2,4,...] — this has a period-2 pattern: [2; (2,4)]. The [2,2] occurs crossing period boundaries: terms 1-2: [2,2] then [2,4] then [2,4]...
√41: CF period [6,2,2,12] — contains [2,2] as a contiguous pair within the period.
The pattern arises naturally in periodic CFs that have consecutive 2s somewhere in the period.
### About "non-quadratic approximants"
Interpretation 1: The **convergents themselves** are rational numbers (algebraic degree 1, not quadratic). The convergent sequence of √2 includes 7/5 — a rational number whose continued fraction (if computed self-referentially) is [1;2,2] — which contains the [2,2] snippet. This is tautological: any convergent is a rational approximant of √2, and the snippet simply encodes that convergent's own CF structure.
Interpretation 2: **Approximants of non-quadratic numbers**. Our random sample shows [2,2] appears by chance in transcendentals (e.g., rand(2.7) had it). The frequency is not obviously elevated.
### Computational limitations
Our survey only inspects first 3040 CF terms and 50 small quadratic radicands. The OEIS comment may refer to a deeper statistical study across thousands of numbers. We did not perform hypothesis testing.
---
## Gap Analysis
| What we know | What remains open |
|---|---|
| √2 has CF [1;2,2,2,...] → [2,2] appears infinitely | The original OEIS question's framing ("non-quadratic approximants") remains ambiguous — we need the exact wording |
| Other √n sometimes have [2,2] in their period | No statistical comparison: is [2,2] more frequent than, say, [1,1] or [3,3]? |
| Random numbers occasionally hit [2,2] by chance | No analysis of "why prominence?" — what metric defines prominence? |
| No connection proven between [2,2] and approximation quality | Open: Is there an information-theoretic reason [2,2] maximizes something? |
**Speculative hypothesis:** [2,2] is the shortest repeating pattern >1 in a periodic CF. For √2, the fundamental unit in (√2) is 1+√2 ≈ 2.414, which has CF [2;2,2,2,...]. This might reflect group structure of the unit group.
---
## Outcome Classification
**Partial progress**
We have:
- ✓ Located the candidate and verified the [2,2] snippet in √2 CF
- ✓ Computed statistical evidence across 40+ numbers
- ✓ Identified that other √n also exhibit [2,2] when their period contains consecutive 2s
- ✓ Clarified the ambiguity in "non-quadratic approximants"
We have *not*:
- ✗ Provided a rigorous proof of why the pattern appears in √2 (this is a standard result about simple periodic CFs)
- ✗ Answered the OEIS question conclusively
- ✗ Submitted an OEIS comment / created a short note
---
## Artifacts Generated
This attack packet itself is the primary artifact. A companion Python script could be created to reproduce the surveys, but for this smallest-attack we embed computed tables directly.
**Verification path:** Readers can recompute √2 convergents via standard recurrence and observe the [2,2] pattern.
---
## Next Attack Recommendations
Based on this first pass:
1. **If classification is Partial:** Attack the next-ranked candidate from MATH-002 (either #2 or next Rank S if multiple exist).
2. **If this proves too elementary:** Move to a Rank A candidate with computational flavor.
3. **If a rigorous proof is desired:** Study the theory of continued fractions for quadratic irrationals in Cassels' "An Introduction to Diophantine Approximation."
---
*"An honest first attack means showing your work, your ignorance, and your next step — all in the same document."*

View File

@@ -0,0 +1,127 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import random as std_random
import pytest
ROOT = Path(__file__).resolve().parent.parent
GAME_PATH = ROOT / "evennia" / "timmy_world" / "game.py"
def load_game_module(tmp_path: Path):
spec = spec_from_file_location("evennia_local_world_game_energy", GAME_PATH)
module = module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
module.random.seed(0)
module.WORLD_DIR = tmp_path
module.STATE_FILE = tmp_path / "game_state.json"
module.TIMMY_LOG = tmp_path / "timmy_log.md"
module.WORLD_DIR.mkdir(parents=True, exist_ok=True)
return module
def make_engine(tmp_path: Path):
module = load_game_module(tmp_path)
engine = module.GameEngine()
engine.start_new_game()
return module, engine
def choose_action(module, engine):
energy = engine.world.characters["Timmy"]["energy"]
room = engine.world.characters["Timmy"]["room"]
actions = module.PlayerInterface(engine).get_available_actions()
if energy <= 1 and "rest" in actions:
return "rest"
if room == "Garden" and "speak:Marcus" in actions and energy <= 4.3:
return "speak:Marcus"
if room == "Forge" and "tend_fire" in actions:
return "tend_fire"
if room == "Tower" and "write_rule" in actions:
return "write_rule"
if room == "Bridge" and "carve" in actions:
return "carve"
if room == "Garden" and "plant" in actions:
return "plant"
moves = [action.split(" ->")[0] for action in actions if action.startswith("move:")]
if moves:
return moves[0]
if "look" in actions:
return "look"
return actions[0]
class TestTowerGameEnergyConstraints:
def test_low_energy_blocks_costly_action_without_crashing(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 1
result = engine.run_tick("move:north")
assert any("too exhausted" in line for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Threshold"
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(0.7)
def test_tired_move_uses_extra_effort_and_drains_energy(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 3.3
result = engine.run_tick("move:north")
assert any("more effort than usual" in line for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Tower"
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(0.0)
assert result["timmy_energy"] == pytest.approx(0.0)
def test_rest_is_meaningful_choice_with_room_specific_recovery(self, tmp_path):
module, garden_engine = make_engine(tmp_path / "garden")
garden_engine.world.characters["Timmy"]["room"] = "Garden"
garden_engine.world.characters["Timmy"]["energy"] = 1.0
garden_result = garden_engine.run_tick("rest")
module, bridge_engine = make_engine(tmp_path / "bridge")
bridge_engine.world.characters["Timmy"]["room"] = "Bridge"
bridge_engine.world.characters["Timmy"]["energy"] = 1.0
bridge_result = bridge_engine.run_tick("rest")
assert any("stone bench" in line for line in garden_result["log"])
assert any("no place to rest" in line.lower() for line in bridge_result["log"])
assert garden_engine.world.characters["Timmy"]["energy"] > bridge_engine.world.characters["Timmy"]["energy"]
def test_marcus_can_provide_energy_relief_when_timmy_is_tired(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["room"] = "Garden"
engine.world.characters["Timmy"]["energy"] = 4.3
result = engine.run_tick("speak:Marcus")
assert any("Marcus offers you food" in line for line in result["log"])
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(5.0)
assert result["timmy_energy"] == pytest.approx(5.0)
def test_energy_collapse_moves_timmy_and_records_memory(self, tmp_path, monkeypatch):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 0
engine.world.characters["Timmy"]["room"] = "Threshold"
monkeypatch.setattr(std_random, "choice", lambda seq: "Tower")
result = engine.run_tick("look")
assert any("collapse from exhaustion" in line.lower() for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Tower"
assert engine.world.characters["Timmy"]["energy"] == 2
assert "Collapsed from exhaustion." in engine.world.characters["Timmy"]["memories"]
def test_intentional_play_reaches_low_energy_within_100_ticks(self, tmp_path):
module, engine = make_engine(tmp_path)
min_energy = engine.world.characters["Timmy"]["energy"]
for _ in range(100):
action = choose_action(module, engine)
engine.run_tick(action)
min_energy = min(min_energy, engine.world.characters["Timmy"]["energy"])
assert min_energy <= 3

View File

@@ -203,26 +203,25 @@ class World:
def update_world_state(self):
"""World changes independent of character actions."""
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
new_room = rooms[0] # Will change to random
attempts += 1
if new_room != current:
char["room"] = new_room
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
# Forge fire naturally dims if not tended
self.state["forge_fire_dying"] = random.random() < 0.1
# Random weather events
@@ -671,7 +670,21 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -734,7 +747,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -848,7 +861,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= 0
self.world.characters["Timmy"]["energy"] -= action_cost
else:
scene["log"].append(f"{target} is not in this room.")
@@ -885,7 +898,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= 2
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -914,7 +927,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -940,7 +953,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -949,7 +962,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -975,7 +988,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= action_cost
scene["log"].append(f"You help {target_name}. They look grateful.")
elif timmy_action.startswith("confront:"):
@@ -1076,6 +1089,9 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f"{char_name} says: \"{line}\"")
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()