Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions docs/front-ends/tui-crawler.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The crawler's loop is a `dispatch` function that turns one line of typed text in
--8<-- "examples/tui_crawler/__main__.py:render-events"
```

Every event has a [`Visibility`][osrlib.core.events.Visibility]. The crawler filters on `Visibility.PLAYER` here to keep referee-only bookkeeping out of the player's terminal. When you run the milestone script (`--seed 21 --script examples/tui_crawler/scripts/milestone.txt`), the game opens like this:
Every event has a [`Visibility`][osrlib.core.events.Visibility]. The crawler filters on `Visibility.PLAYER` here to keep referee-only bookkeeping out of the player's terminal. When you run the milestone script (`--seed 39 --script examples/tui_crawler/scripts/milestone.txt`), the game opens like this:

```text
> enter
Expand All @@ -29,8 +29,8 @@ Every event has a [`Visibility`][osrlib.core.events.Visibility]. The crawler fil
> move e
The party moves to (2, 0), facing east.
The party enters area guard_room (level 1).
Encounter: 2 × Goblin at 20' — the party is surprised.
The monsters' bearing: uncertain.
Encounter: 2 × Goblin at 20' — the monsters are surprised.
The monsters' bearing: hostile.
```

The second line shows why the result envelope exists. When the party crossed the threshold, the interpreter activated the adventure's quest with a command it issued *inside* the player's `enter`, and the session added that command's events to the result of the `enter`.
Expand Down Expand Up @@ -99,9 +99,10 @@ The interpreter is an ordinary [`Listener`][osrlib.crawl.session.Listener]: it r

```text
> take idol_shrine
character-0001 acquires jade-idol and 17 gp in coin.
character-0003 acquires 17 gp in coin.
character-0004 acquires 16 gp in coin.
character-0001 acquires 13 gp in coin.
character-0002 acquires jade-idol and 13 gp in coin.
character-0003 acquires 12 gp in coin.
character-0004 acquires 12 gp in coin.
Quest The Jade Idol: objective Recover the idol is done. The idol comes up out of the hollow, cold as well-water.
```

Expand All @@ -110,28 +111,32 @@ Then, four `move w` steps later, the homecoming:
```text
> town
The party enters town town.
The adventure ends: 0 XP from monsters and 50 XP from treasure — 16 XP to each of 3 survivor(s).
character-0001 gains 16 XP (base 16), now level 1.
character-0003 gains 17 XP (base 16), now level 1.
character-0004 gains 17 XP (base 16), now level 1.
The adventure ends: 0 XP from monsters and 50 XP from treasure — 12 XP to each of 4 survivor(s).
character-0001 gains 12 XP (base 12), now level 1.
character-0002 gains 12 XP (base 12), now level 1.
character-0003 gains 12 XP (base 12), now level 1.
character-0004 gains 10 XP (base 12), now level 1.
Quest The Jade Idol: objective Bring it home is done. Threshold's gate shuts behind you with the idol inside it.
Quest complete: The Jade Idol. The almoner counts out the reward without looking up. The idol is home.
The adventure is over: The Jade Idol is finished. The almoner counts out the reward without looking up. The idol is home.
character-0001 acquires 200 gp in coin.
character-0001 gains 1260 XP (base 1200), now level 1.
character-0003 gains 1320 XP (base 1200), now level 2.
character-0003 advances to level 2 (Footpad): +4 hp (rolled 4).
character-0004 gains 1320 XP (base 1200), now level 1.
character-0001 gains 1260 XP (base 1200), now level 2.
character-0001 advances to level 2 (Warrior): +9 hp (rolled 8).
character-0002 gains 1200 XP (base 1200), now level 2.
character-0002 advances to level 2 (Adept): +4 hp (rolled 3).
character-0003 gains 1200 XP (base 1200), now level 2.
character-0003 advances to level 2 (Footpad): +4 hp (rolled 2).
character-0004 gains 1080 XP (base 1200), now level 1.
```

Two details of that output need explaining. The engine spreads the cache across the party by the ordinary loot rules, so which character walks home with the idol is whatever the split produced. Here it's the fighter. The cleric isn't in the list because the cleric died in the vault. The objective's `has_item` condition tests whether *the party* has the idol, not which member does. The completion beat then appears twice, on the quest's event and again on the adventure's, because each event includes the authored line and the formatter appends the beat on whatever event it's given.
Two details of that output need explaining. The engine spreads the cache across the party by the ordinary loot rules, so which character walks home with the idol is whatever the split produced. Here it's the cleric. All four members are in the list, because nobody died on the delve. The objective's `has_item` condition tests whether *the party* has the idol, not which member does. The completion beat then appears twice, on the quest's event and again on the adventure's, because each event includes the authored line and the formatter appends the beat on whatever event it's given.

### Why the milestone makes two trips

The homecoming objective is a `town_entered` pattern narrowed by a `has_item` condition, so when the party walks back empty-handed, the objective doesn't fire. That condition is why `scripts/milestone.txt` makes four legs:

1. **Down**, for the goblins, their lair hoard, and the rival party prowling level 2.
2. **Home without the idol.** Returning to town resolves the end-of-adventure award, and the party sells its haul and buys a temple healing. Those town commands are legal here and nowhere later, because the adventure has not ended yet. Each coin counts as one coin of weight toward encumbrance, so the character who sold the haul uses `give` to spread the coins across the party before anyone moves again.
2. **Home without the idol.** Returning to town resolves the end-of-adventure award, and the party sells its haul and buys a temple healing. Those town commands are legal here and nowhere later, because the adventure has not ended yet. Each coin counts as one coin of weight toward encumbrance, so the characters who sold the richest pieces use `give` to spread the coins across the party before anyone moves again.
3. **Down again**, for the idol alone.
4. **Home with it**, which completes the second objective, completes the quest, and, because the quest has `concludes_adventure=True`, ends the session in `victory`. The rewards arrive *after* that transition: the 200 gp, the party's XP, and the `quest.idol` flag the crawler prints on its way out.

Expand Down
2 changes: 1 addition & 1 deletion examples/tui_crawler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ quit
Non-interactive mode replays a transcript with a fixed party and seed:

```sh
uv run python -m examples.tui_crawler --seed 21 --script examples/tui_crawler/scripts/milestone.txt
uv run python -m examples.tui_crawler --seed 39 --script examples/tui_crawler/scripts/milestone.txt
```

That transcript is the milestone playthrough, in two trips: the delve, a generated
Expand Down
77 changes: 53 additions & 24 deletions examples/tui_crawler/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from osrlib.core.events import Visibility
from osrlib.core.items import Coins
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.battle import MELEE_RANGE_FEET, preview_battle_round
from osrlib.crawl.commands import (
BattleDeclaration,
EngageBattle,
Expand Down Expand Up @@ -80,36 +81,64 @@ def _first_weapon_id(member) -> str | None:


def _battle_round(session) -> ResolveBattleRound:
"""One auto-declared battle round: front rank attacks, the rest close or hold.

Both the roster and the rank come from the player view rather than a guess.
The round must name exactly the members able to act — a slept member named
alongside the rest bounces the whole round (`roster_mismatch`) — and how many
can fight abreast depends on the space the party stands in — two in a ten-foot
passage, more in a room — so a front end that assumed the first two would both
waste a legal attack and, in a narrow place, declare an illegal one and lose
the round.
"""One auto-declared battle round: every member closes on the nearest group and fights it.

The plan comes from the player view and the round preview, as any front end would build it.
The round must name exactly the members able to act, so the roster is the view's `declarers`.
A slept member named alongside the rest bounces the whole round (`roster_mismatch`). Each
member then gets the first of these plans that the preview accepts.

1. A member in the group's front rank attacks where they stand.
2. A member who can reach the group this round attacks with `move="close"`, when the front rank
toward it still has a place. The front rank is as wide as the space the party stands in
allows: two in a ten-foot passage, more in a room.
3. A member farther than 5' away closes without attacking.
4. Anyone else holds.
"""
view = session.view(Visibility.PLAYER)
group = next(entry for entry in session.encounter.groups if not entry.fled)
encounter = view.encounter
rates = {member.id: member.encounter_rate_feet for member in view.party}
members = {member.id: member for member in session.party.members}
front = view.encounter.front_rank
declarations = []
for member in (members[member_id] for member_id in view.encounter.declarers):
weapon_id = _first_weapon_id(member)
if group.distance_feet > 5:
declarations.append(
BattleDeclaration(character_id=member.id, action="move", move="close", target_group_id=group.id)
groups = [group for group in encounter.groups if group.count and not group.fled]
if not groups:
return ResolveBattleRound(
declarations=tuple(
BattleDeclaration(character_id=member_id, action="hold") for member_id in encounter.declarers
)
elif member.id in front:
declarations.append(
BattleDeclaration(
character_id=member.id, action="attack", target_group_id=group.id, weapon_id=weapon_id
)
group = min(groups, key=lambda entry: min(entry.member_distance_feet.values()))
planned: list[BattleDeclaration] = []
for member_id in encounter.declarers:
distance = group.member_distance_feet[member_id]
attack = BattleDeclaration(
character_id=member_id,
action="attack",
target_group_id=group.id,
weapon_id=_first_weapon_id(members[member_id]),
)
candidates = []
if member_id in group.front_rank:
candidates.append(attack)
else:
taken = preview_battle_round(session, planned).front_rank[group.id]
room = encounter.formation_width is None or len(taken) < encounter.formation_width
if room and distance <= rates[member_id] + MELEE_RANGE_FEET:
candidates.append(attack.model_copy(update={"move": "close"}))
if distance > MELEE_RANGE_FEET:
candidates.append(
BattleDeclaration(character_id=member_id, action="move", move="close", target_group_id=group.id)
)
planned.append(
next(
(
candidate
for candidate in candidates
if not preview_battle_round(session, [*planned, candidate]).rejections[member_id]
),
BattleDeclaration(character_id=member_id, action="hold"),
)
else:
declarations.append(BattleDeclaration(character_id=member.id, action="hold"))
return ResolveBattleRound(declarations=tuple(declarations))
)
return ResolveBattleRound(declarations=tuple(planned))


def _fight(session) -> None:
Expand Down
12 changes: 7 additions & 5 deletions examples/tui_crawler/scripts/milestone.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ town
# Town, while there is still an adventure to come back from: sell the haul, buy
# the healing, bank the first award.
sell all
heal character-0001 cure_light_wounds
# The cleric took the worst of the rivals' blows, so the temple's healing goes
# to the cleric.
heal character-0002 cure_light_wounds
# Coin weighs a coin apiece, and the party moves at its slowest member's rate, so
# the seller's purse is spread before anybody walks anywhere. The cleric fell in
# the vault and coin does a corpse no good, so the load goes to the two who walk.
give character-0001 character-0003 550
give character-0001 character-0004 550
# the sellers' purses are spread before anybody walks anywhere. The fighter and the
# cleric sold the richest pieces, so they hand coin to the two who carry least.
give character-0001 character-0004 400
give character-0002 character-0003 250
status
# Trip two: the idol, and the walk home that ends it.
enter
Expand Down
Loading
Loading