Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
f68f110d0e feat: add tower npc relationship graph for #515
Some checks failed
Agent PR Gate / gate (pull_request) Failing after 13s
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 17s
Smoke Test / smoke (pull_request) Failing after 18s
Agent PR Gate / report (pull_request) Successful in 15s
2026-04-22 02:14:03 -04:00
Alexander Whitestone
289f0410aa test: define tower npc relationships for #515 2026-04-22 02:13:46 -04:00
5 changed files with 174 additions and 307 deletions

View File

@@ -12,6 +12,27 @@ WORLD_DIR = Path('/Users/apayne/.timmy/evennia/timmy_world')
STATE_FILE = WORLD_DIR / 'game_state.json'
TIMMY_LOG = WORLD_DIR / 'timmy_log.md'
FRIENDSHIP_THRESHOLD = 0.5
TENSION_THRESHOLD = -0.5
NPC_RELATIONSHIP_SEEDS = {
("Kimi", "Marcus"): {
"values": {"Kimi": 0.45, "Marcus": 0.47},
"conversation": "While you are away, Marcus and Kimi trade a quiet confidence beneath the oak.",
"milestone": "A friendship starts to take root between Marcus and Kimi.",
"hint": "Marcus and Kimi move with the easy familiarity of old friends.",
"delta": 0.08,
"kind": "friendship",
},
("Bezalel", "ClawCode"): {
"values": {"Bezalel": -0.46, "ClawCode": -0.44},
"conversation": "While you are away, Bezalel and ClawCode clash over what the forge is for.",
"milestone": "Tension hardens between Bezalel and ClawCode at the anvil.",
"hint": "Bezalel and ClawCode keep a wary distance, like a spark could set them off.",
"delta": -0.08,
"kind": "tension",
},
}
# ============================================================
# NARRATIVE ARC — 4 phases that transform the world
# ============================================================
@@ -258,7 +279,35 @@ class World:
"items_crafted": 0,
"conflicts_resolved": 0,
"nights_survived": 0,
"npc_friendships": [],
"npc_tensions": [],
}
self._initialize_npc_relationships(apply_seeds=True)
def _initialize_npc_relationships(self, apply_seeds=False):
npc_names = [name for name, char in self.characters.items() if not char.get("is_player", False)]
for npc_name in npc_names:
trust_map = self.characters[npc_name]["trust"]
for other_name in npc_names:
if other_name != npc_name:
trust_map.setdefault(other_name, 0.0)
if apply_seeds:
for pair, seed in NPC_RELATIONSHIP_SEEDS.items():
left, right = pair
self.characters[left]["trust"][right] = seed["values"][left]
self.characters[right]["trust"][left] = seed["values"][right]
self.state.setdefault("npc_friendships", [])
self.state.setdefault("npc_tensions", [])
def relationship_hint_for_room(self, room_name, occupants):
hints = []
occupant_set = set(occupants)
for bucket in ("npc_friendships", "npc_tensions"):
for entry in self.state.get(bucket, []):
pair = set(entry.get("pair", []))
if entry.get("room") == room_name and pair.issubset(occupant_set):
hints.append(entry.get("hint", ""))
return [hint for hint in hints if hint]
def tick_time(self):
"""Advance time of day."""
@@ -389,6 +438,8 @@ class World:
here = [n for n, c in self.characters.items() if c["room"] == room_name and n != char_name]
if here:
desc += f"\n Here: {', '.join(here)}"
for hint in self.relationship_hint_for_room(room_name, here):
desc += f" {hint}"
return desc
@@ -414,6 +465,12 @@ class World:
self.rooms = data.get("rooms", self.rooms)
self.characters = data.get("characters", self.characters)
self.state = data.get("state", self.state)
needs_seed = not any(
any(other != "Timmy" for other in char.get("trust", {}))
for name, char in self.characters.items()
if not char.get("is_player", False)
)
self._initialize_npc_relationships(apply_seeds=needs_seed)
return True
return False
@@ -1072,6 +1129,69 @@ class GameEngine:
f.write(f"\n*Began: {datetime.now().strftime('%Y-%m-%d %H:%M')}*\n\n")
f.write("---\n\n")
f.write(message + "\n")
def _adjust_mutual_trust(self, left, right, delta):
for speaker, listener in ((left, right), (right, left)):
trust_map = self.world.characters[speaker]["trust"]
trust_map[listener] = max(-1.0, min(1.0, trust_map.get(listener, 0.0) + delta))
def _record_relationship_milestone(self, scene, room_name, pair, bucket, milestone, hint):
pair_list = list(pair)
entries = self.world.state.setdefault(bucket, [])
if any(entry.get("pair") == pair_list for entry in entries):
return
entries.append({
"pair": pair_list,
"room": room_name,
"summary": milestone,
"hint": hint,
})
scene["world_events"].append(milestone)
def _run_offscreen_npc_relationships(self, scene):
timmy_room = self.world.characters["Timmy"]["room"]
rooms = {}
for char_name, char in self.world.characters.items():
if char.get("is_player", False):
continue
rooms.setdefault(char["room"], []).append(char_name)
for room_name, occupants in rooms.items():
if room_name == timmy_room or len(occupants) < 2:
continue
occupant_set = set(occupants)
for pair, seed in NPC_RELATIONSHIP_SEEDS.items():
if not set(pair).issubset(occupant_set):
continue
left, right = pair
self._adjust_mutual_trust(left, right, seed["delta"])
scene["npc_actions"].append(f"{left} and {right} speak in The {room_name} while you are away.")
scene["world_events"].append(seed["conversation"])
self.world.characters[left]["spoken"].append(seed["conversation"])
self.world.characters[right]["spoken"].append(seed["conversation"])
self.world.characters[left]["memories"].append(seed["conversation"])
self.world.characters[right]["memories"].append(seed["conversation"])
left_trust = self.world.characters[left]["trust"][right]
right_trust = self.world.characters[right]["trust"][left]
if seed["kind"] == "friendship" and left_trust >= FRIENDSHIP_THRESHOLD and right_trust >= FRIENDSHIP_THRESHOLD:
self._record_relationship_milestone(
scene,
room_name,
pair,
"npc_friendships",
seed["milestone"],
seed["hint"],
)
elif seed["kind"] == "tension" and left_trust <= TENSION_THRESHOLD and right_trust <= TENSION_THRESHOLD:
self._record_relationship_milestone(
scene,
room_name,
pair,
"npc_tensions",
seed["milestone"],
seed["hint"],
)
def run_tick(self, timmy_action="look"):
"""Run one tick. Return the scene and available choices."""
@@ -1397,6 +1517,8 @@ class GameEngine:
self.world.characters[char_name]["room"] = dest
self.world.characters[char_name]["energy"] -= 1
scene["npc_actions"].append(f"{char_name} moves from The {old_room} to The {dest}")
self._run_offscreen_npc_relationships(scene)
# Random NPC events — phase-aware speech
room_name = self.world.characters["Timmy"]["room"]

View File

@@ -1,111 +0,0 @@
#!/bin/bash
# ============================================================================
# Agent Dispatch — One-shot prompt generator for fleet workers
# ============================================================================
# Refs: timmy-home #512
#
# Packages context, token, repo, issue, and Git/Gitea commands into a
# copy-pasteable prompt for any agent (Claude, Sonnet, Kimi, Grok, etc.).
#
# Usage:
# scripts/agent-dispatch.sh <agent> <repo> <issue#> [<org>]
#
# Supported agents:
# sonnet, claude, kimi, grok, gemini, ezra, bezalel, allegro, timmy
#
# Example:
# scripts/agent-dispatch.sh sonnet the-nexus 844 Timmy_Foundation
# ============================================================================
set -euo pipefail
AGENT="${1:-}"
REPO="${2:-}"
ISSUE="${3:-}"
ORG="${4:-Timmy_Foundation}"
TOKEN="${GITEA_TOKEN:-$(cat ~/.config/gitea/token 2>/dev/null || true)}"
FORGE="https://forge.alexanderwhitestone.com"
if [ -z "$AGENT" ] || [ -z "$REPO" ] || [ -z "$ISSUE" ]; then
echo "Usage: $0 <agent> <repo> <issue#> [<org>]"
echo ""
echo "Supported agents:"
echo " sonnet — Anthropic Claude Sonnet (cloud, high-reasoning)"
echo " claude — Anthropic Claude (general)"
echo " kimi — Moonshot Kimi K2.5 (cloud, long-context)"
echo " grok — xAI Grok (cloud, real-time)"
echo " gemini — Google Gemini (cloud, multimodal)"
echo " ezra — Local archivist house (read-before-write)"
echo " bezalel — Local artificer house (proof-required)"
echo " allegro — Local dispatch house (tempo-and-routing)"
echo " timmy — Local sovereign house (final review)"
exit 1
fi
# Validate agent
VALID_AGENTS="sonnet claude kimi grok gemini ezra bezalel allegro timmy"
if ! echo "$VALID_AGENTS" | grep -qw "$AGENT"; then
echo "ERROR: Unknown agent '$AGENT'"
echo "Valid agents: $VALID_AGENTS"
exit 1
fi
# Fetch issue details
if [ -n "$TOKEN" ]; then
ISSUE_JSON=$(curl -s -H "Authorization: token ${TOKEN}" \
"${FORGE}/api/v1/repos/${ORG}/${REPO}/issues/${ISSUE}" 2>/dev/null || true)
ISSUE_TITLE=$(echo "$ISSUE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('title',''))" 2>/dev/null || true)
ISSUE_BODY=$(echo "$ISSUE_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('body',''))" 2>/dev/null || true)
else
echo "WARNING: No Gitea token found. Issue details will be blank."
ISSUE_TITLE=""
ISSUE_BODY=""
fi
cat <<EOF
================================================================================
DISPATCH PROMPT — ${AGENT} → ${ORG}/${REPO}#${ISSUE}
================================================================================
Agent: ${AGENT}
Repo: ${ORG}/${REPO}
Issue: #${ISSUE}
Title: ${ISSUE_TITLE}
--- ISSUE BODY ---
${ISSUE_BODY}
--- INSTRUCTIONS ---
1. Clone the repo:
git clone --depth 1 "https://\${TOKEN}@forge.alexanderwhitestone.com/${ORG}/${REPO}.git"
cd ${REPO}
2. Create branch:
git checkout -b ${AGENT}/${REPO}-${ISSUE}
3. Read the issue, implement the fix or feature.
4. Test your changes locally.
5. Commit and push:
git add -A
git commit -m "[${AGENT}] ${ISSUE_TITLE} (#${ISSUE})"
git push origin ${AGENT}/${REPO}-${ISSUE}
6. Open PR via Gitea API:
curl -X POST \\
-H "Authorization: token \${TOKEN}" \\
-H "Content-Type: application/json" \\
"${FORGE}/api/v1/repos/${ORG}/${REPO}/pulls" \\
-d '{"title":"[${AGENT}] ${ISSUE_TITLE}","head":"${AGENT}/${REPO}-${ISSUE}","base":"main","body":"Closes #${ISSUE}"}'
7. File new issues for anything discovered.
Token: \${GITEA_TOKEN} or ~/.config/gitea/token
Forge: ${FORGE}
Sovereignty and service always.
================================================================================
EOF

View File

@@ -1,195 +0,0 @@
#!/bin/bash
# ============================================================================
# Sonnet Workforce Smoke Test
# ============================================================================
# Refs: timmy-home #512
#
# Validates that the Sonnet workforce agent can perform the full
# clone → code → commit → push → PR workflow via Gitea HTTP.
#
# Usage:
# scripts/sonnet-smoke-test.sh [--cleanup]
#
# Exit codes:
# 0 — all checks passed
# 1 — one or more checks failed
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TOKEN="${GITEA_TOKEN:-$(cat ~/.config/gitea/token 2>/dev/null || true)}"
FORGE="https://forge.alexanderwhitestone.com"
ORG="Timmy_Foundation"
REPO="timmy-home"
TEST_BRANCH="smoke/sonnet-$(date +%s)"
# Colors
GREEN='\\033[0;32m'
RED='\\033[0;31m'
YELLOW='\\033[0;33m'
NC='\\033[0m'
PASS=0
FAIL=0
log_pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
log_fail() { echo -e "${RED}${NC} $1"; FAIL=$((FAIL + 1)); }
log_info() { echo -e "${YELLOW}${NC} $1"; }
# ── Prerequisites ──────────────────────────────────────────────────────────────────────────────────────
log_info "Checking prerequisites..."
if [ -z "$TOKEN" ]; then
log_fail "Gitea token not found (checked GITEA_TOKEN env and ~/.config/gitea/token)"
exit 1
fi
if ! command -v git &>/dev/null; then
log_fail "git not installed"
exit 1
fi
if ! command -v curl &>/dev/null; then
log_fail "curl not installed"
exit 1
fi
if ! command -v python3 &>/dev/null; then
log_fail "python3 not installed"
exit 1
fi
log_pass "Prerequisites OK"
# ── 1. Clone via Gitea HTTP ───────────────────────────────────────────────────────────────────────────────────────────────────────
log_info "Step 1: Clone repo via Gitea HTTP..."
TMPDIR=$(mktemp -d)
CLONE_URL="${FORGE}/${ORG}/${REPO}.git"
cd "$TMPDIR"
if git clone --depth 1 "https://${TOKEN}@${FORGE#https://}/${ORG}/${REPO}.git" smoke-clone 2>/dev/null; then
log_pass "Clone via Gitea HTTP"
else
log_fail "Clone via Gitea HTTP"
rm -rf "$TMPDIR"
exit 1
fi
# ── 2. Commit ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
log_info "Step 2: Create branch and commit..."
cd "$TMPDIR/smoke-clone"
git checkout -b "$TEST_BRANCH" 2>/dev/null || true
# Make a harmless change
printf "# Sonnet smoke test marker\\n# timestamp: %s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > SONNET_SMOKE_MARKER.md
git add SONNET_SMOKE_MARKER.md
if git -c user.email="sonnet@timmy.local" -c user.name="Sonnet Smoke Test" \
commit -m "test: sonnet smoke test marker" 2>/dev/null; then
log_pass "Commit created"
else
log_fail "Commit failed"
rm -rf "$TMPDIR"
exit 1
fi
# ── 3. Push ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
log_info "Step 3: Push branch..."
if git push origin "$TEST_BRANCH" 2>/dev/null; then
log_pass "Push to origin"
else
log_fail "Push to origin"
rm -rf "$TMPDIR"
exit 1
fi
# ── 4. Create PR ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
log_info "Step 4: Create PR via Gitea API..."
PR_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE}/api/v1/repos/${ORG}/${REPO}/pulls" \
-d "{
\"title\": \"test: sonnet smoke test ${TEST_BRANCH}\",
\"head\": \"${TEST_BRANCH}\",
\"base\": \"main\",
\"body\": \"Automated smoke test verifying Sonnet can clone, commit, push, and open a PR.\\n\\nRefs #512\"
}" 2>/dev/null)
PR_NUMBER=$(echo "$PR_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('number',''))")
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "None" ]; then
log_pass "PR created (#${PR_NUMBER})"
PR_URL="${FORGE}/${ORG}/${REPO}/pulls/${PR_NUMBER}"
echo " URL: $PR_URL"
else
log_fail "PR creation failed"
echo " Response: $PR_RESPONSE"
rm -rf "$TMPDIR"
exit 1
fi
# ── 5. Verify PR exists ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
log_info "Step 5: Verify PR exists via API..."
PR_CHECK=$(curl -s -H "Authorization: token ${TOKEN}" \
"${FORGE}/api/v1/repos/${ORG}/${REPO}/pulls/${PR_NUMBER}" 2>/dev/null)
PR_STATE=$(echo "$PR_CHECK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('state',''))")
if [ "$PR_STATE" = "open" ]; then
log_pass "PR verified open via API"
else
log_fail "PR state is '$PR_STATE', expected 'open'"
fi
# ── Cleanup (optional) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
if [ "${1:-}" = "--cleanup" ]; then
log_info "Cleaning up smoke test artifacts..."
curl -s -X PATCH -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE}/api/v1/repos/${ORG}/${REPO}/pulls/${PR_NUMBER}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
git push origin --delete "$TEST_BRANCH" 2>/dev/null || true
log_pass "Cleanup complete"
fi
rm -rf "$TMPDIR"
# ── Summary ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "================================================================"
echo " Sonnet Smoke Test Summary"
echo "================================================================"
echo -e " Passed: ${GREEN}${PASS}${NC}"
echo -e " Failed: ${RED}${FAIL}${NC}"
echo ""
if [ "$FAIL" -gt 0 ]; then
echo -e "${RED}RESULT: FAILED${NC}"
exit 1
else
echo -e "${GREEN}RESULT: PASSED${NC}"
echo ""
echo "Sonnet workforce is verified end-to-end:"
echo " ✓ Clone via Gitea HTTP"
echo " ✓ Branch + commit"
echo " ✓ Push to origin"
echo " ✓ Open PR via API"
echo " ✓ Verify PR state"
exit 0
fi

View File

@@ -0,0 +1,52 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parent.parent
GAME_PATH = ROOT / "evennia" / "timmy_world" / "game.py"
def load_game_module():
spec = spec_from_file_location("tower_game_relationships", GAME_PATH)
module = module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
module.random.seed(0)
return module
class TestTowerGameNpcRelationships(unittest.TestCase):
def test_each_npc_tracks_trust_for_every_other_npc(self):
module = load_game_module()
world = module.World()
npc_names = [name for name, char in world.characters.items() if not char.get("is_player", False)]
for npc_name in npc_names:
with self.subTest(npc=npc_name):
trust_map = world.characters[npc_name]["trust"]
expected = set(npc_names) - {npc_name}
self.assertTrue(expected.issubset(set(trust_map)), f"{npc_name} missing NPC trust keys: {sorted(expected - set(trust_map))}")
def test_offscreen_npc_conversations_create_friendship_and_tension(self):
module = load_game_module()
engine = module.GameEngine()
engine.start_new_game()
result = engine.run_tick("look")
friendships = {tuple(rel["pair"]) for rel in engine.world.state["npc_friendships"]}
tensions = {tuple(rel["pair"]) for rel in engine.world.state["npc_tensions"]}
self.assertIn(("Kimi", "Marcus"), friendships)
self.assertIn(("Bezalel", "ClawCode"), tensions)
self.assertTrue(any("while you are away" in line.lower() for line in result["world_events"]))
garden_desc = engine.world.get_room_desc("Garden", "Timmy")
forge_desc = engine.world.get_room_desc("Forge", "Timmy")
self.assertIn("Marcus and Kimi", garden_desc)
self.assertIn("Bezalel and ClawCode", forge_desc)
if __name__ == "__main__":
unittest.main()

View File

@@ -38,7 +38,6 @@ class House(Enum):
EZRA = "ezra" # Archivist, reader
BEZALEL = "bezalel" # Artificer, builder
ALLEGRO = "allegro" # Tempo-and-dispatch, connected
SONNET = "sonnet" # Anthropic Claude Sonnet (cloud, high-reasoning)
class Mode(Enum):