a character with Speed gets a LogicMovementComponent on the client, and
until now we had no encoder for it, so any snapshot carrying a unit
would have desynced. the layout is four booleans, a path length, that
many path nodes, and eighteen more vints - one conditional, no data
driven ones. charge time defaults to the -1 the client uses when
ChargeRange is empty, which is every card but the Prince.
with that in place the session pushes a fresh SectorStateMessage once a
second down the battle ticker. every push runs through verify_snapshot
first and is dropped rather than sent if it does not read back, the same
guard that caught the decks.
the verifier learned the movement pass, and lost the leftover
SCROLL_BATTLE_SUMMONER switch that the builder had already shed.
crowns now move because the server finally hurts things. each tick it
retargets, moves, resolves attacks and buries the dead, in that order.
the numbers are the client's own. Speed is position units per tick, so a
knight at 60 covers 1.2 tiles a second; SightRange and Range are in the
same units with 1000 to a game tile, and the target's CollisionRadius is
added on the far side of both. distances compare squared with the
client's saturation rule - beyond 46340 on either axis, or on overflow,
the distance is INT_MAX rather than a wrapped negative. the attack timer
counts milliseconds fifty at a time and fires once LoadTime + HitSpeed
have passed, then rewinds to LoadTime. tower damage comes from
projectiles.csv, not from the Damage column of buildings.csv, which is
empty for them.
a princess tower leaving the board is struck from leader_towers, which is
what getStars reads, so crowns follow from the same list the client
keeps.
what is deliberately not modelled yet: pathfinding, so units walk
straight at their target instead of along the roads and over the bridges;
collision, pushback and avoidance; projectiles as travelling objects,
since the damage lands the moment the attack fires; and buffs. field_48
on the combat component is renamed hit_timer after what it holds.
the client never receives individual commands in a battle - SectorManager
has receiveSectorState, receiveCompressedSectorState and a heartbeat, and
nothing else. so everything the opponent does has to reach the player
inside server state, which means the server needs to be able to speak
first. it could not: the rpc only ever answered.
the gateway now runs a ticker for the length of a battle, calling a new
battle_tick on the service five times a second and writing whatever it
returns straight to the socket. the simulation stays in the service and
the socket stays in the gateway.
the first rider is emotes. the player's SendBattleEventMessage reaches
the service instead of being logged and dropped, and the bot answers with
a taunt of its own; it also sends one unprompted every twelve to thirty
seconds, drawn from the rows of taunts.csv that TauntMenu marks as
usable. the reply carries the opponent account so it renders on their
side of the arena.
commands carry tick_when_given and execute_tick, so a spawn no longer
lands the moment its turn message arrives. it is queued and released on
its own tick while advance_to steps the battle one tick at a time, which
is what makes the simulation reproducible from the command stream rather
than from when packets happened to arrive.
the per-tick object update goes in this loop next.
a LogicDoSpellCommand names a deck slot, so the card comes from the deck
we sent for that player, and the owner comes from matching the command's
executor account against the two the battle carries. the spell row's
SummonCharacter and SummonNumber say what and how many to place, at the
position the command carries.
the object factory behind the towers is now shared: hitpoints and the
combat/hitpoint/buff components are built the same way for a summoned
character as for a tower.
these objects are not encodable yet. a character with Speed gets a
LogicMovementComponent on the client, and we have no encoder for that
component, so the server copy must not be turned back into a snapshot
until it exists. nothing re-encodes it today - it feeds the clock, the
crowns and the end condition only.
LogicBattle::isEndConditionMatched, transcribed: the battle is over when
end_counter is positive, when either king is dead, when tick/20 seconds
reach MatchLength + OvertimeSeconds, or - once past MatchLength - when
the crowns differ. the divisor is the 0x66666667/2^35 multiply in the
client, which is a divide by twenty, so the battle runs at 20 ticks a
second.
crowns come from LogicSummoner::getStars: three when the enemy king is
down, otherwise two minus the enemy princess towers still standing,
which is exactly what leader_towers holds.
BattleRegistry keeps the LogicGameMode we built for each account and
advances it on the tick the client reports in its turn message, so the
server now tracks the clock, the crowns and whether the battle is over,
and drops the session when the player goes home. the two isSummoner
guards in the overtime branch are left out: they only fire for an object
that is not a summoner, which a king tower always is.
BattleScreen::createBattleEndHUD takes a null result message and builds
the end screen from the local battle, then BattleScreen::sendGoHomeMessage
fires - so in this mode the client decides the battle is over and asks to
go home on its own. we now clear the stopped flag on that request instead
of leaving the session wedged until the next login.
battle turns are logged with their tick, checksum and command types while
the flag is up, so the traffic at the end of a battle is visible.
the card bar was empty because the hand lives on the king tower, not on
the battle. LogicSummoner::decode gates a whole block behind one boolean
- setEncodeDeckDataEnabled on the client side - and we always wrote it
false, so the client kept the hand and the draw queue it was born with,
which is nothing.
the block holds four deck indices for the hand, then two int lists: the
draw queue getNextSpell walks and the used pile reshuffleDeck folds back
into it. we now hand out the first four slots and queue the rest, leave
the used pile empty and keep last used at -1, which is what
getLastUsedSpell reads as "none".
starting mana comes from the START_MANA global instead of zero, and the
field after last-used is named after clearSpellCooldowns, which is the
only thing that writes it.
the decks were the visible half: LogicBattle carries one LogicSpellDeck
per player and we wrote both as absent, so the client cleared them and
the card bar came up empty. both sides get a real deck now - the player
from their profile, the bot from the fullest row of predefined_decks.
matchmaking no longer picks a row out of npcs.csv. it takes the location
from the arena's PvpLocation column and builds an opponent avatar with
its own name, arena and trophies, so the battle reads as a player match
rather than a trainer one. StartMissionMessage still goes through the
npc path unchanged.
the battle type stays 1 on purpose. LogicGameMode::isImmediateMessageExecution
is (type - 1) < 3, so 1, 2 and 3 let the client simulate locally while 0
makes it wait for the server to drive the sector - which needs the real
tick loop we do not have yet.
the client sends EndClientTurnMessage during the battle too, but its
tick and checksum belong to the battle, not to the home. we kept
comparing them against the home checksum and answered with
OutOfSyncMessage, which is the "Client and server are out of sync!"
dialog on tick 60.
HomeMode carries the stopped flag now and returns an empty turn result
while it is set, and sector_state_for raises it, so both the mission and
the matchmaking entry points are covered.
the base object writes owner index, component mask, position and z, in
that order. we had the first field unnamed and were writing the owner
into the z slot, so every object belonged to player 0, and the mask slot
carried a zero.
the mask matters: getHitpointComponent tests bit 2 of it before touching
components[2], so a zero mask made every tower report no hitpoints,
LogicCharacter::isAlive fell through to the z field, shouldDestruct went
true and the towers were destroyed on the first tick - which then hit
"cant find summoner tower" in LogicBattle::removeGameObjectReferences.
entries are built through LogicGameObjectEntry::new now so the mask is
derived from the component array and cannot drift from it.
also rename the first character flag after what sets it: kamikaze
death and morph both raise it right before the object is removed.
SectorManager::receiveSectorState reads one byte off the front of the
message body and branches on it: 1 goes to receiveCompressedSectorState,
anything else falls through to the plain decode. we never wrote that
byte, so the client ate the first byte of the snapshot as the flag and
then read every field one byte early - server tick 0, discard 11, and
the section sentinel landed on the LogicTime tick instead of 11.
drop the two bisect switches, they served their purpose: both settings
aborted identically, which is what ruled out the object payload.
the client aborts in Debugger::error("Full update stream is corrupted!"), tail
called out of LogicGameMode::decode, which is why the stack blames
receiveSectorState. only one error path exists there so it is a sentinel.
verify_snapshot reads the snapshot back following the client decoders and
refuses to send anything that does not land both sentinels with no trailing
bytes. it passes today, so the layout matches what i believe the client reads.
checked every reader against the binary: LogicGameMode, LogicBattle,
LogicGameObjectManager, LogicGameObject, LogicCharacter, LogicSummoner, all
three components, LogicClientAvatar, readGlobalID, readDataReference,
readConstantSizeIntArray, readGameObjectReference, decodeComponent, and
setLevelIndex for the component set. all match.
SCROLL_BATTLE_SUMMONER=0 encodes the king towers as plain characters,
SCROLL_BATTLE_PRINCESS=0 drops the princess towers. one client run with each
splits the remaining hypotheses.
the client reads getTable(8)->getItemCount() entries there, table 8 is
damage_types with 5 rows. i was writing character_buffs, 12 rows, so every
snapshot carried 7 extra vints and 7 extra booleans and the stream slid.
that is what tripped the sentinel and aborted the client in
Debugger::error("Full update stream is corrupted!").
verified the rest field by field against the client decoders: LogicGameMode,
LogicBattle, LogicGameObjectManager, LogicGameObject, LogicCharacter,
LogicSummoner and LogicCombatComponent all match. the conditional character
fields stay off because ReloadAfterHits and ManaGenerateLimit are empty for both
tower rows.
537 only tells the client's ui to start searching, the handshake after it was
missing: server sends 24106 StopHomeLogic, client answers 14105, server sends
21903. that last step now builds the same snapshot the npc mission does, using
npcs row 0 as the opponent.
battle type stays 1 so it runs on the client's offline path. a real pvp battle
is type 0 and needs the udp sector channel, which does not exist here.
14107 CancelMatchmake now answers 24125 instead of being ignored.
npc missions used to get a ServerErrorMessage back. now StartMission builds a
LogicGameMode snapshot off the arena tilemap and answers 21903.
towers come from assets/locations/*.csv the way initDefaultSector does it: tile
coordinates times 500, leader index decided by which half of the map the tower
sits in. the two king towers must be there, the client dereferences them without
a null check. they live in buildings.csv, not characters.csv.
LogicCharacter puts the base object fields fourth, not first. the buff component
writes a fixed array sized by the character_buffs row count even with no buffs.
training_arena parses to 2 kings, 4 princess towers, 36x64 subtiles. snapshot is
602 bytes over 6 objects. the real client has not seen it yet.
RandomSpells / DifferentSpells / RareChance / EpicChance / MinGold / MaxGold all
come off the chest row now, gold is scaled by the player's arena. magic chest is
30 cards over 8 cards with 1 epic and 6 rares, like the client says it is.
arena chest rows inherit everything through BaseChest, so Free_Arena1 reads Free.
rounding is banded and the 5s band biases +3, so 22 goes to 25 not 20. numbers match the csv for arena 1 and 2.
free chest end timestamp needs migration 0002.
if the client presents an id we don't know, create it under that id and bump the sequence past it.
also chest offers match through BaseChest, so one silver row covers every arena.
json stores are gone, they nulled out every data ref on load and wrote the null straight back on save.
shop json and the purchase commands ended up in here too.