Commit graph

19 commits

Author SHA1 Message Date
WiseDev
eb0e59f6f4 give every object a new id once the client is live
the client never asks for the state, so the objects it decoded before
the battle screen installed its listener were stuck without models for
the whole battle - and every later snapshot matched them by global id
and reused them, so the "newly created" flag the visual depends on was
never set again.

the first sector command is the client telling us it is up. at that
point every object is handed a fresh id: nothing matches, so the client
builds them all from scratch and they get models. the leaders and the
tower lists are remapped with them and the column is re-sorted, since
the client binary searches it.

the battle itself is complete as of this run: a princess tower fell and
the first crown was scored, towers (2, 1) and stars (1, 0) in the log.
2026-08-23 14:36:16 +03:00
WiseDev
8699a6884d answer the client when it asks for the sector state
RequestSectorStateMessage, 12903, one vint of client tick, sent through
sendUdpMessage and so arriving on tcp like everything else. we were
ignoring it.

it matters because of how the client builds models. the factory marks a
freshly created object at [obj+0x14], and only for those does
LogicGameObjectManager::decode call the listener at [mgr+0x28] that
builds the visual. every later snapshot matches the same object by
global id and reuses it, so the flag is never set again - an object that
was decoded before the battle screen installed its listener stays
invisible for the whole battle while still walking and fighting. that is
the tower archers and the invisible units; the knight shows because the
client creates that one itself, after the screen is up.

the client asks for the state when it is ready, and now it gets it.
2026-08-23 14:32:20 +03:00
WiseDev
cc92831d06 never reuse a global id
the client matches the objects in a snapshot against the ones it already
has by global id, and anything it cannot find it builds from scratch.
our next id was the highest one in play plus one, recomputed at every
spawn - so as soon as a unit died its id came free and the next spawn
took it. at five snapshots a second the client was destroying and
rebuilding models faster than they could appear, which is why units
fought and shot while invisible.

the counter is per battle now and only ever goes up, starting past the
towers.
2026-08-23 14:17:50 +03:00
WiseDev
a355b77514 hold the bot back until the intro is over
the bot's first card was due on tick one, so it was already walking
while the opening countdown was still on screen - reaching the bridge
and swinging at nothing before the battle had visibly started. it waits
six seconds now before opening, and keeps its four to eleven second
rhythm after that.
2026-08-23 14:13:27 +03:00
WiseDev
bab24f2295 send the battle state five times a second
the server was simulating correctly all along - the log shows a goblin
covering 120 units a tick and a knight 60, exactly their Speed columns,
in a straight line with no jitter. the teleporting was the correction
rate: one snapshot a second, while the client runs its own prediction in
between and diverges from ours. a unit gets more than a tile out of step
before our state arrives and drags it back.

the snapshot now goes every four ticks and the gateway ticks every fifty
milliseconds rather than two hundred, so a correction moves a unit a
fifth as far.

this makes the symptom smaller, not absent. it goes away when the two
simulations agree, which is what the checksum comparison is for and what
collisions and projectiles are still missing for.
2026-08-23 14:10:55 +03:00
WiseDev
9230a212ef keep the path instead of finding it again every tick
the tick was running A* over the whole 36x64 grid for every unit, every
tick. catching up ten seconds meant thousands of searches inside the
session lock, so the tick loop never finished, snapshots never went out,
and the client - which refuses to send a command while
isFullUpdatePending is true - sat there showing the connection icon and
would not spawn anything. ctrl-c looked like a hang for the same reason:
a task stuck in that loop.

the client does not do this either. LogicMovementComponent carries a
path array precisely so the route is found once and walked. we keep the
route and the goal it was found for, drop a node once we are within
250 units of it, and only search again when the goal moves or the route
runs out.

advance_to also refuses to simulate more than forty ticks in one call,
so a late tick can never turn into an unbounded loop under the lock.
2026-08-23 14:03:26 +03:00
WiseDev
f51d76f780 stamp the tick on the snapshot that carries it
the snapshot is built inside the tick loop but the clock was written
after it, so every pushed state went out carrying the tick from the
previous call. LogicGameMode::decode compares that number against the
last one it saw and ignores anything older, so a stale stamp is the
difference between a state being applied and being dropped on the floor.
the clock advances with the tick now.

the sector command log gains the summoner mana, how many snapshots have
gone out, and the type of the command that arrived, so the next run says
whether the push is happening at all rather than leaving it to inference.
2026-08-23 13:58:48 +03:00
WiseDev
dbbd162bed regenerate mana on the server
at type 0 the client stopped working the elixir bar out for itself and
started reading it from the summoner in our snapshot - which never
moved, so the bar sat where the opening state left it and no card could
be afforded.

the rule from LogicSummoner::tick: an accumulator gains five thousand a
tick and one mana is granted for every MANA_REGEN_MS * 100 / MAX_MANA it
holds, the remainder carried rather than dropped. that works out at
2.8 seconds a mana with the shipped globals, and halves in the last
sixty seconds through MANA_REGEN_MS_END, which is the speed-up the game
has always had.
2026-08-23 13:53:41 +03:00
WiseDev
438e2c6491 compare the server checksum against the client's
LogicGameMode::calculateChecksum runs the whole game mode through a
ChecksumEncoder - in a battle there is no client home, so it is the same
encode as the snapshot with the command manager left out. we compute the
same number now and log it beside the one the client sends in every turn
message, with whether they agree.

that comparison only means something because of what turned up while
reading the encoder: the two vints the decoder throws away are not
padding. the client writes getCheckSum() into both, once after the
server tick and once after the tutorial manager. we were writing zero,
which parses fine - the decoder discards them either way - but poisons
the running checksum, so the numbers could never have matched. they
carry the real running value now.

the two will not agree yet. the point is to see how far apart they are
and where, since the gap is what stands between this and battle type 0.
2026-08-23 13:44:51 +03:00
WiseDev
d1cc829623 send units at the nearest tower when nothing is in sight
units stood still all battle. a unit only moved if it had a combat
target, and targets are only found inside SightRange - six thousand
units for a knight - while the enemy tower sits twenty thousand away. so
nothing ever walked, nothing was ever hit, no tower fell, crowns stayed
at nothing and the battle could only end on the clock.

a unit with no target now walks at the nearest enemy building, which is
the default target the client falls back to. once the tower comes inside
sight the ordinary search picks it up and the attack timer takes over.

the battle turn log carries the standing tower count per side so damage
is visible before it becomes a crown.
2026-08-23 13:28:23 +03:00
WiseDev
9084163f71 walk units around the river instead of through it
units now path. A* over the tilemap grid, water costing 800 against 1
for ground so the route runs to a bridge, or 20 when the unit flies and
crosses anywhere. bit 5 of a map cell is the water flag, which is what
makes 48 water and leaves the bridge cells - carrying the lane ids 1 and
2 - dry. the unit walks to the next node rather than at its target, and
still stops at Range.

also stops pushing snapshots while the battle type is 1. the client is
simulating the same battle itself there, and our state does not match it
yet, so every push yanked the units back to where the server thought
they were. the snapshots are still built and verified each second, ready
for the switch to type 0 once the two simulations agree; they are simply
not sent.
2026-08-23 13:23:58 +03:00
WiseDev
ba235a9b4c matchmake two players before falling back to a bot
matchmaking now queues. the first player in waits, the second one to
arrive pairs with them, and both get the same battle: one shared session
keyed by both accounts, one snapshot, the client working out which side
it is from the account ids it already carries. if nobody turns up within
ten seconds the ticker polls the queue out and builds the bot battle
instead. cancelling or disconnecting leaves the queue.

also fixes the movement component tail: the extracted layout counts
"n + 18" vints including the path length itself, so seventeen follow the
path, not eighteen. the verifier read one too many and every snapshot
carrying a moving unit came apart after it - which is what the guard
caught and refused to send, rather than the client aborting on it.
2026-08-23 13:19:38 +03:00
WiseDev
3f0cf3d1d4 let the bot play cards
every four to eleven seconds the bot draws a card from the deck we gave
it and puts it down on its own side, on the lane of whichever of its
towers stands furthest forward. the card goes through the same summon
factory the player's cards use, so it lands in the server simulation and
reaches the player inside the next snapshot rather than through any
special path.

placement comes from the bot's own tower positions rather than from the
tilemap, so the session does not need the map threaded into it.
2026-08-23 13:15:47 +03:00
WiseDev
3bb923cd17 encode the movement component and push snapshots
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.
2026-08-23 13:14:23 +03:00
WiseDev
054321ad50 simulate movement, targeting and damage on the server
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.
2026-08-23 13:10:30 +03:00
WiseDev
2ea283cdda push messages to the client during a battle
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.
2026-08-23 13:03:07 +03:00
WiseDev
d6cfc81c71 play spawns on the tick the command names
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.
2026-08-23 12:30:46 +03:00
WiseDev
83a1dd2bd3 spawn the played card on the server
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.
2026-08-23 12:09:30 +03:00
WiseDev
7a0ba1b652 give the server its own battle clock and end condition
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.
2026-08-23 12:01:26 +03:00