Skip to content

Add the contract for member positions and moving and attacking in one round - #215

Merged
mmacy merged 7 commits into
mainfrom
contract/battle-positions
Oct 31, 2026
Merged

mmacy merged 7 commits into
mainfrom
contract/battle-positions

Conversation

@mmacy

@mmacy mmacy commented Oct 31, 2026

Copy link
Copy Markdown
Owner

This is the contract for issue #209: each party member stands at their own distance from the monsters, and a member can close in and attack in the same round. It adds stubs, docstrings, and acceptance tests, and changes no behavior. The tests are marked expected-to-fail for the chunks that implement them: positions, move-and-attack, monster-reach, and view-and-codes.

The rules the contract states:

  • Each member has an advance, in feet forward of the party line, stored in BattleState.advance_feet. A member's distance to a group is the group's distance minus the member's advance.
  • A close moves only the member who declares it, at their own encounter rate. An attack can set move="close" to close and attack in one round. A cast can't be combined with a move, because the OSE SRD says "The spell caster cannot move and cast a spell in the same round."
  • A fighting withdrawal or retreat by one member moves that member away at their own rate. A formation move still moves the party as one body.
  • The front rank toward a group is the nearest members within 5', as many as the formation width allows. Arrival order, then marching order, decides who gets a place.
  • A monster group closes until it's within 5' of the nearest visible member and stops, so the front line screens the members behind it. Monsters can close and attack in the same round.
  • A thief who closes and back-stabs in the free round after a surprise needs no rule of its own.
  • When every visible member stands behind the party line, the line moves back to the foremost member.
  • A melee attack that position stops reports a DeclarationFizzledEvent with the new code battle.melee.fizzled.
  • New view fields give a battle screen each member's distances, each group's front rank, light and sight, the formation width, the free round, and each member's rate per round. preview_battle_round dry-runs the declarations planned so far.

Every change is additive under schema_version 4. The phase 4 and phase 5 golden tests are marked expected-to-fail without strict mode, because the goldens change when the positions chunk lands and are regenerated at the end.

The lead developer of The Snoring King, a game built on osrlib, reviewed the design and these public names.

https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD

mmacy added 7 commits October 30, 2026 22:36
…ng and attacking in one round

Issue #209: each party member stands at their own distance from the monsters, and a member can close in and attack in the same round. A game built on osrlib (The Snoring King) builds its battle screen from these public names, so this commit adds them as stubs before any rule changes. The battle code uses none of it yet, so every battle plays as before.

The contract:

- BattleState.advance_feet holds each member's place on the range track, in feet forward of the party line, where the party stood when the encounter opened. BattleState.arrived_round holds the round each member got there. Both default to empty, so an older save loads with everyone on the line. A member's distance to a group is the group's distance_feet minus the member's advance.
- A close moves only the member who declares it, at their own encounter rate, doubled by haste on them. It stops 5' from the named group, and short of 5' from every other group still in the fight. A member already within 5' doesn't move.
- A fighting withdrawal or a retreat that not every declarer makes moves that member alone, back at half or full rate, below the line if need be. Any member can make one, and it needs no replacement. A formation move works as before, and a formation retreat's pursuit starts from the nearest living member.
- An attack can set move="close". The session judges it as if every declared move had happened, for the distance, the default weapon, reach, and the front rank. combat.attack.out_of_reach reports the distance the move would leave. A melee attack resolves only if the member stands in the group's front rank within 5' at the melee step, or the group is fleeing and was within the member's reach when the round was accepted.
- A cast that sets a move is refused with battle.declaration.cast_cannot_move. A turn_undead, use_item, or hold that sets one, and an attack with a fighting_withdrawal or a retreat, get battle.declaration.move_not_combinable. Both have the member's id as character, and the session checks them after the back-stab checks and before the action's own. An attack that closes, by a member who can't move, gets battle.declaration.cannot_move.
- replacement_id is now a trade of marching-order places, reported by MemberWithdrewEvent right after the withdrawing member's move. The named member has to be another declarer who isn't withdrawing too and isn't named twice, or the round gets battle.declaration.replacement_unavailable. The trade happens only when the move does.
- The ranks toward a group are the living members sorted by their distance to it and cut into rows of the formation width. A row never mixes distances. The member who arrived earlier stands in front, and marching order settles a tie.
- A group closes until it's within 5' of the nearest member it can see, and its melee reaches only its front rank within 5'. The default policies close and attack in the same round when the move reaches. NPC archers shoot at the first row toward their group. Gazes, breath, and friendly fire read the ranks by distance.
- MemberMovedEvent, code battle.member.moved, reports each member's own move with character_id, move, advance_feet, group_id, and distance_feet. It has a message template, and MemberWithdrewEvent's two messages now describe the trade.
- osrlib.crawl.battle gains member_distance_feet and group_front_rank, which raise NotImplementedError until the positions chunk, and preview_battle_round, a dry run of a round's planned declarations that returns a BattleRoundPreview and raises NotImplementedError until the move-and-attack chunk.
- The player view gains EncounterGroupView.member_distance_feet, front_rank, lit, and seen_by, EncounterView.advance_feet, formation_width, and party_free_round, and MemberView.encounter_rate_feet. They return their defaults until the view-and-codes chunk. EncounterView.front_rank is now the first row toward the nearest group, which is its old value until members move apart.

Every change is a new field with a default, a new event, a new code, or a newly accepted combination of values, so schema_version stays 4.

The docstrings of BattleDeclaration, ResolveBattleRound, the battle module, the default policies, and the affected events and views state these rules. The ResolveBattleRound example now plans each round with the dry run, so it's registered as expected to fail until the move-and-attack chunk, and it prints nothing until the goldens chunk captures its output. The rejection-code docs gate is marked xfail for the move-and-attack chunk, which emits the two new codes and adds their descriptions to tools/docs/rejection_codes.json.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
…and monster reach

These are the tests the chunks for issue #209 have to make pass without editing them. Each test is marked xfail with strict=True and the reason of the chunk that completes it, so continuous integration stays green and a marked test that starts passing early fails loudly. Each test asserts its own preconditions, builds its battle by command from a seeded session, and checks that every refused round and every dry run costs nothing.

- tests/positions_fixtures.py builds the walk-through party: two fighters and a cleric in plate (20' a round), a thief in leather (30'), an elf in chain mail (20'), and a magic-user in no armour (40'), each with scores of 10 and 400 hit points. A test policy holds the monsters still while a test sets members at their distances.
- tests/test_member_positions.py, chunk: positions. A close moves only its member at their own rate and stops 5' from the group and short of any other group. Withdrawals alone move the member back at half or full rate with no replacement. The trade changes only the marching order. Arrival rounds, ranks toward a group, group_front_rank, member_distance_feet, melee judged by distance, formation moves, the pursuit gap, the end of the battle, saves, and replays.
- tests/test_move_and_attack.py, chunk: move-and-attack. Close and attack in one round, reach as rate plus 5', close and shoot, the default weapon by the distance after the move, the front rank after every declared move, cast_cannot_move, move_not_combinable, cannot_move on a closing attack, the melee step's rule for a group that runs first, a spell's range from the caster's place, and preview_battle_round, which must agree with the round it previews.
- tests/test_monster_reach.py, chunk: monster-reach. A group stops 5' from the nearest member it can see, never walks past a visible member, passes an invisible one, and stops at a statue. Its melee reaches only its front rank, it closes and strikes in one round, and a melee out of reach is skipped. NPC archers shoot at the first row, a gaze reaches the front rank within reach, a cone breath covers the members nearer than its length, and friendly fire catches the member within reach.
- tests/test_back_stab.py, chunk: move-and-attack. A thief closes and back-stabs in the free round from 35' in leather and 45' in no armour, is out of reach beyond that, and reaches twice as far under haste. Guards keep the back-stab checks before the move checks.
- tests/test_battle_player_view.py, chunk: view-and-codes. The view's member distances, front rank per group, advances, formation width, free-round flag, encounter rates, light, and sight. The walk-through battle of the design's section 8 plays end to end from the player view and the dry run: the thief back-stabs in the free round, the elf shoots from where she stands, the fighters close in, the magic-user casts magic missile from the back, and no goblin ever reaches the elf, the magic-user, or the cleric. It needs every chunk, so its marker comes off last.

The unmarked tests in these files are guards that pass before and after the chunks.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
Under issue #209 a close moves only its member, a withdrawal alone moves its member back with no replacement, and replacement_id is a trade of marching-order places. The tests that held the old rules now state the new ones, under the marker of the chunk that delivers them. The coders can't edit tests, so tests that hooked private functions the chunks will replace now hook something that stays.

- tests/test_battle.py: a lone close moves only that member and stops 5' from the group. A lone fighting withdrawal or retreat, a minority retreating, and two members withdrawing alone are each accepted and move only their members. These are marked for chunk: positions. The protection-from-evil test builds its target list directly instead of calling _party_target_pool.
- tests/test_single_withdrawal.py: the swap, its rank distance, and its refusals become the move and the trade. A member falls back at half or full rate, names anyone who is another declarer and isn't withdrawing, and needs to name nobody. A member who casts, stands in front, or can't move can be named. The tests for those are marked for chunk: positions. The retreat's exposure, melee around the trade, the views, determinism, and the formation moves stay as unmarked guards. The test of a named member who can't act when the moment comes now paralyses them as initiative is rolled, and the trade still happens.
- tests/test_turning_and_staff.py: the staff of striking's fizzle when the trade didn't happen now paralyses the fighter as initiative is rolled, instead of wrapping _swap_ranks.
- tests/test_weapon_default_and_device_recheck.py: the weapon chosen at acceptance is now shown with the goblins closing before the party's missile phase, instead of a thief's close that moved the whole party. The weakened-cleric test wraps the magic phase instead of _party_movement.
- tests/test_npc.py and tests/test_fastapi_crawler.py: the battle drivers try an attack with the first two members and close when the session refuses it, instead of closing while the group is beyond 5'. A refused round costs nothing, so the accepted rounds are the same as before under today's rules.
- tests/test_phase4_goldens.py, the milestone classes of tests/test_phase5_goldens.py, and tests/test_example_crawler.py are marked xfail with strict=False for chunk: goldens, because their battles close on groups. They pass today and are regenerated once the rules change.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
A group's distance_feet can't go below zero, so without this rule a group could never reach members who had all fallen back behind the party line one at a time.

The rule: at the end of the party's movement step, when every living member who isn't invisible stands behind the party line, the largest advance among them being below zero, the line moves back to that foremost member. Every member's advance and every group's distance_feet grow by the same number of feet, so every member's distance to every group stays the same. The session emits no event for it, because nobody moved toward or away from anyone. EncounterView.advance_feet and each group's distance_feet show the moved values. A member's MemberMovedEvent reports their advance right after their move, before the line moves. When every living member is invisible, the line stays where it is. The preview of a round projects the same move of the line. Monsters then close as usual and reach the foremost member.

The docstrings that define the party line and advances state the rule: BattleState.advance_feet, the battle module's terms, member_distance_feet, BattleRoundPreview.advance_feet, BattleDeclaration.move, EncounterGroup.distance_feet, GroupMovedEvent, MemberMovedEvent, and the two view fields.

Tests under chunk: positions in tests/test_member_positions.py: a lone withdrawal that takes the whole party behind the line moves the line back with every distance unchanged, a group closes on and reaches a party that fell back one member at a time, a member at or forward of the line keeps it where it is, and an invisible member on the line doesn't hold it. The dry run's projection of the move is tested under chunk: move-and-attack, and the view's moved values under chunk: view-and-codes. The ResolveBattleRound example's registration follows its new line range.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
…ts the same value

The rule has two halves, applied one after the other. Arrival order, then marching order, decides who is in the front row toward a group: the member who reached the distance in an earlier round stands in front, and marching order settles a tie. The returned tuple then lists the members of that row in marching order, so a member who got there first can come after a member who arrived later. For the same group, group_front_rank(session, group_id) and EncounterGroupView.front_rank are always the same value.

The docstrings of group_front_rank and EncounterGroupView.front_rank state both halves and the equality. A test under chunk: view-and-codes in tests/test_battle_player_view.py compares the two for two groups after each of five rounds in which members close, arrive at the same distance in different rounds, and fall back.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
…pursuit starts

The rule: when every declarer makes the same fighting withdrawal or retreat, the party moves as one body at the slowest living member's encounter rate, halved for a fighting withdrawal. Every group's distance grows by that rate and no member's advance changes, so members who stand apart stay apart. After a formation retreat, the pursuit's starting gap is the smallest distance from any living member to any group that can follow, measured after the move.

BattleDeclaration.move and PursuitState.gap_feet state the rule. A test under chunk: positions in tests/test_member_positions.py has a thief 25' forward and a magic-user 40' back retreat with the rest of the party from sleeping goblins and wakeful kobolds. Both groups' distances grow by 20', nobody's advance changes, and the chase starts 55' from the kobolds, which is the thief's distance to them. The ResolveBattleRound example's registration follows its new line range.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
The rule: a declared melee attack resolves only if, when the party's melee step comes, the member stands in the group's front rank within 5' of it. When it doesn't resolve for that reason, the session emits a player-visible DeclarationFizzledEvent at the code battle.melee.fizzled, so the player sees why the fighter didn't swing. Its actor_id is the member, its group_id is the group the attack was aimed at, and its reason is combat.attack.out_of_reach when the member is farther than 5' from the group, or battle.declaration.not_in_front_rank when the member is within 5' but not in the front rank. That happens, for example, when the monsters win initiative and the group runs before the member's close, or when a member the round expected to step back can no longer move. The exception for a fleeing group stays: a member who stood within 5' of it when the round was accepted still swings, and no fizzle is reported.

DeclarationFizzledEvent gains the code battle.melee.fizzled and an optional group_id field that defaults to None. Both are additive. The event has a message template that says the member can't reach the group or can't get into its front rank. The docstrings of DeclarationFizzledEvent, BattleDeclaration.move, ResolveBattleRound, and the battle module state the rule.

Tests under chunk: move-and-attack in tests/test_move_and_attack.py: the monsters win initiative and run out of reach, so the closing thief's attack fizzles as out of reach, and a member left out of the front rank because the fighter who was to step back was paralysed fizzles as not in the front rank. Unmarked guards check the event's shape, its message, and that a member within reach of a group that runs first still swings with no fizzle. The ResolveBattleRound example's registration follows its new line range.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
@mmacy
mmacy merged commit f994f0c into main Oct 31, 2026
5 checks passed
@mmacy
mmacy deleted the contract/battle-positions branch October 31, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant