@v12-engine/engine 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/abilities/buffs.d.ts +83 -0
- package/dist/abilities/castTold.d.ts +12 -0
- package/dist/abilities/charges.d.ts +28 -0
- package/dist/abilities/dispel.d.ts +9 -0
- package/dist/abilities/effectFields.d.ts +10 -0
- package/dist/abilities/effects/buff.d.ts +8 -0
- package/dist/abilities/effects/core.d.ts +1 -0
- package/dist/abilities/effects/damage.d.ts +1 -0
- package/dist/abilities/effects/heal.d.ts +2 -0
- package/dist/abilities/effects/missile.d.ts +1 -0
- package/dist/abilities/effects/moves.d.ts +11 -0
- package/dist/abilities/effects/passives.d.ts +15 -0
- package/dist/abilities/effects/sight.d.ts +4 -0
- package/dist/abilities/effects/spawn.d.ts +2 -0
- package/dist/abilities/effects/timed.d.ts +4 -0
- package/dist/abilities/effects/trade.d.ts +1 -0
- package/dist/abilities/effects.d.ts +122 -0
- package/dist/abilities/land.d.ts +21 -0
- package/dist/abilities/scaled.d.ts +21 -0
- package/dist/abilities/text.d.ts +113 -0
- package/dist/ai/area.d.ts +53 -0
- package/dist/ai/brain.d.ts +528 -0
- package/dist/ai/budget.d.ts +67 -0
- package/dist/ai/checks.d.ts +115 -0
- package/dist/ai/computer.d.ts +166 -0
- package/dist/ai/defence.d.ts +108 -0
- package/dist/ai/define.d.ts +21 -0
- package/dist/ai/economy.d.ts +129 -0
- package/dist/ai/geometry.d.ts +35 -0
- package/dist/ai/goals.d.ts +360 -0
- package/dist/ai/index.d.ts +38 -0
- package/dist/ai/knowledge.d.ts +418 -0
- package/dist/ai/lanes.d.ts +232 -0
- package/dist/ai/melee/build.d.ts +61 -0
- package/dist/ai/melee/goals.d.ts +299 -0
- package/dist/ai/melee/heroes.d.ts +27 -0
- package/dist/ai/melee/index.d.ts +14 -0
- package/dist/ai/melee/production.d.ts +8 -0
- package/dist/ai/melee/surrender.d.ts +24 -0
- package/dist/ai/micro.d.ts +278 -0
- package/dist/ai/mission.d.ts +195 -0
- package/dist/ai/missionRules.d.ts +18 -0
- package/dist/ai/module.d.ts +116 -0
- package/dist/ai/options.d.ts +61 -0
- package/dist/ai/plans/build.d.ts +280 -0
- package/dist/ai/plans/placement.d.ts +82 -0
- package/dist/ai/plans/plans.d.ts +105 -0
- package/dist/ai/plans/production.d.ts +210 -0
- package/dist/ai/plans/purse.d.ts +73 -0
- package/dist/ai/plans/tech.d.ts +37 -0
- package/dist/ai/profiles.d.ts +204 -0
- package/dist/ai/reach.d.ts +4 -0
- package/dist/ai/report.d.ts +203 -0
- package/dist/ai/roles.d.ts +133 -0
- package/dist/ai/rules.d.ts +288 -0
- package/dist/ai/scouting.d.ts +98 -0
- package/dist/ai/settings.d.ts +73 -0
- package/dist/ai/squads.d.ts +429 -0
- package/dist/ai/strength.d.ts +167 -0
- package/dist/ai/task.d.ts +118 -0
- package/dist/ai/tasks/attack.d.ts +7 -0
- package/dist/ai/tasks/capture.d.ts +21 -0
- package/dist/ai/tasks/creep.d.ts +16 -0
- package/dist/ai/tasks/defend.d.ts +29 -0
- package/dist/ai/tasks/drop.d.ts +34 -0
- package/dist/ai/tasks/escort.d.ts +16 -0
- package/dist/ai/tasks/gather.d.ts +23 -0
- package/dist/ai/tasks/harass.d.ts +7 -0
- package/dist/ai/tasks/hold.d.ts +20 -0
- package/dist/ai/tasks/index.d.ts +32 -0
- package/dist/ai/tasks/move.d.ts +19 -0
- package/dist/ai/tasks/patrol.d.ts +8 -0
- package/dist/ai/tasks/retreat.d.ts +20 -0
- package/dist/ai/tasks/scout.d.ts +29 -0
- package/dist/ai/tasks/shared.d.ts +74 -0
- package/dist/ai/team.d.ts +155 -0
- package/dist/ai/threat.d.ts +91 -0
- package/dist/ai/view.d.ts +366 -0
- package/dist/ai/waves.d.ts +126 -0
- package/dist/ai.js +6239 -0
- package/dist/ai.js.map +1 -0
- package/dist/aiTesting.d.ts +179 -0
- package/dist/areas.d.ts +149 -0
- package/dist/attacks.d.ts +66 -0
- package/dist/attributes.d.ts +66 -0
- package/dist/brain-BvVdUhX8.js +7659 -0
- package/dist/brain-BvVdUhX8.js.map +1 -0
- package/dist/builds-C6LfgvEU.js +460 -0
- package/dist/builds-C6LfgvEU.js.map +1 -0
- package/dist/bytes.d.ts +6 -0
- package/dist/camps.d.ts +95 -0
- package/dist/capture.d.ts +131 -0
- package/dist/cellLayers.d.ts +75 -0
- package/dist/cinematics.d.ts +194 -0
- package/dist/clock.d.ts +32 -0
- package/dist/commands.d.ts +235 -0
- package/dist/components.d.ts +184 -0
- package/dist/computers.d.ts +108 -0
- package/dist/concealment.d.ts +61 -0
- package/dist/coreComponents-syuh2fPG.js +5149 -0
- package/dist/coreComponents-syuh2fPG.js.map +1 -0
- package/dist/coreComponents.d.ts +1 -0
- package/dist/dashing.d.ts +8 -0
- package/dist/debug.d.ts +36 -0
- package/dist/debug.js +3 -0
- package/dist/debugCommands-CzjrSm66.js +14739 -0
- package/dist/debugCommands-CzjrSm66.js.map +1 -0
- package/dist/debugCommands.d.ts +213 -0
- package/dist/debugging.d.ts +10 -0
- package/dist/definitions.d.ts +43 -0
- package/dist/defs.d.ts +1162 -0
- package/dist/denying.d.ts +81 -0
- package/dist/destructibles.d.ts +27 -0
- package/dist/determinism-BCymQwiE.js +518 -0
- package/dist/determinism-BCymQwiE.js.map +1 -0
- package/dist/determinism.d.ts +121 -0
- package/dist/dialog.d.ts +94 -0
- package/dist/effectRefs.d.ts +16 -0
- package/dist/effects.d.ts +80 -0
- package/dist/endings.d.ts +22 -0
- package/dist/engagement.d.ts +108 -0
- package/dist/engineEvents.d.ts +710 -0
- package/dist/entity.d.ts +409 -0
- package/dist/entityIndex.d.ts +50 -0
- package/dist/errors.d.ts +85 -0
- package/dist/events.d.ts +97 -0
- package/dist/exactMath.d.ts +63 -0
- package/dist/features.d.ts +10 -0
- package/dist/fields.d.ts +114 -0
- package/dist/fixture.d.ts +17 -0
- package/dist/fog.d.ts +110 -0
- package/dist/formation.d.ts +111 -0
- package/dist/friendlyFire.d.ts +26 -0
- package/dist/game-DiJEN-Ks.js +5800 -0
- package/dist/game-DiJEN-Ks.js.map +1 -0
- package/dist/game.d.ts +501 -0
- package/dist/gameData.d.ts +325 -0
- package/dist/gameFiles.d.ts +26 -0
- package/dist/gates.d.ts +28 -0
- package/dist/generate.d.ts +403 -0
- package/dist/generateWorker.d.ts +39 -0
- package/dist/grid.d.ts +53 -0
- package/dist/ground.d.ts +127 -0
- package/dist/groundSpeed.d.ts +22 -0
- package/dist/hero.d.ts +177 -0
- package/dist/home.d.ts +14 -0
- package/dist/hooks.d.ts +780 -0
- package/dist/hotReload.d.ts +76 -0
- package/dist/ids.d.ts +26 -0
- package/dist/index.d.ts +227 -0
- package/dist/index.js +4701 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect-D9dWHmZ0.js +491 -0
- package/dist/inspect-D9dWHmZ0.js.map +1 -0
- package/dist/inspect.d.ts +176 -0
- package/dist/inventory.d.ts +18 -0
- package/dist/items.d.ts +91 -0
- package/dist/lanes.d.ts +213 -0
- package/dist/life.d.ts +53 -0
- package/dist/local-Dkovq42k.js +1379 -0
- package/dist/local-Dkovq42k.js.map +1 -0
- package/dist/log.d.ts +140 -0
- package/dist/manaBurn.d.ts +39 -0
- package/dist/map/file.d.ts +114 -0
- package/dist/map/images.d.ts +18 -0
- package/dist/map/look.d.ts +47 -0
- package/dist/map/pathProfile.d.ts +13 -0
- package/dist/map/resize.d.ts +54 -0
- package/dist/map/schema.d.ts +10 -0
- package/dist/map/terrain.d.ts +32 -0
- package/dist/map/types.d.ts +613 -0
- package/dist/map.d.ts +7 -0
- package/dist/mapCheck/cells.d.ts +19 -0
- package/dist/mapCheck/rule.d.ts +30 -0
- package/dist/mapCheck/rules/names.d.ts +3 -0
- package/dist/mapCheck/rules/objects.d.ts +3 -0
- package/dist/mapCheck/rules/paths.d.ts +3 -0
- package/dist/mapCheck/rules/players.d.ts +3 -0
- package/dist/mapCheck/rules/races.d.ts +3 -0
- package/dist/mapCheck/rules/random.d.ts +3 -0
- package/dist/mapCheck/rules/regions.d.ts +3 -0
- package/dist/mapCheck/rules/targets.d.ts +3 -0
- package/dist/mapCheck/rules/walking.d.ts +3 -0
- package/dist/mapCheck.d.ts +23 -0
- package/dist/mapFields.d.ts +364 -0
- package/dist/mapFile.d.ts +23 -0
- package/dist/mapMeasure.d.ts +95 -0
- package/dist/mapMerge.d.ts +19 -0
- package/dist/mapObjectives.d.ts +82 -0
- package/dist/mapObjects.d.ts +1 -0
- package/dist/math.d.ts +44 -0
- package/dist/messages.d.ts +150 -0
- package/dist/missionRules-C-bGxm0Q.js +523 -0
- package/dist/missionRules-C-bGxm0Q.js.map +1 -0
- package/dist/missionRules.d.ts +179 -0
- package/dist/mods.d.ts +146 -0
- package/dist/names.d.ts +93 -0
- package/dist/navigation/agentParams.d.ts +4 -0
- package/dist/navigation/air.d.ts +89 -0
- package/dist/navigation/builds.d.ts +140 -0
- package/dist/navigation/groundAreas.d.ts +23 -0
- package/dist/navigation/navigation.d.ts +127 -0
- package/dist/navigation/recast.d.ts +214 -0
- package/dist/navigation/sidePaths.d.ts +69 -0
- package/dist/navigation/sized.d.ts +1 -0
- package/dist/navigation-worker.js +11 -0
- package/dist/navigation-worker.js.map +1 -0
- package/dist/nearest.d.ts +8 -0
- package/dist/noise.d.ts +12 -0
- package/dist/objectives.d.ts +93 -0
- package/dist/orders/basic.d.ts +96 -0
- package/dist/orders/build.d.ts +91 -0
- package/dist/orders/cast.d.ts +105 -0
- package/dist/orders/facts.d.ts +14 -0
- package/dist/orders/harvest.d.ts +94 -0
- package/dist/orders/idle.d.ts +7 -0
- package/dist/orders/items.d.ts +14 -0
- package/dist/orders/queue.d.ts +24 -0
- package/dist/pack.schema.json +6621 -0
- package/dist/packs/director/index.d.ts +81 -0
- package/dist/packs/heroes/campaign.d.ts +137 -0
- package/dist/packs/heroes/carry.d.ts +59 -0
- package/dist/packs/heroes/commands.d.ts +6 -0
- package/dist/packs/heroes/hire.d.ts +9 -0
- package/dist/packs/heroes/index.d.ts +12 -0
- package/dist/packs/heroes/making.d.ts +16 -0
- package/dist/packs/heroes/respawn.d.ts +42 -0
- package/dist/packs/moba/index.d.ts +34 -0
- package/dist/packs/pack.d.ts +84 -0
- package/dist/packs-g5thvmGn.js +3199 -0
- package/dist/packs-g5thvmGn.js.map +1 -0
- package/dist/packs.d.ts +84 -0
- package/dist/paths.d.ts +51 -0
- package/dist/plain.d.ts +17 -0
- package/dist/player.d.ts +117 -0
- package/dist/portals.d.ts +66 -0
- package/dist/prefabs.d.ts +139 -0
- package/dist/presetBuildings.d.ts +52 -0
- package/dist/presetGround.d.ts +22 -0
- package/dist/profiles-B3NS9jwk.js +335 -0
- package/dist/profiles-B3NS9jwk.js.map +1 -0
- package/dist/races.d.ts +136 -0
- package/dist/randomGroups.d.ts +2 -0
- package/dist/registry.d.ts +464 -0
- package/dist/remains.d.ts +119 -0
- package/dist/replication.d.ts +103 -0
- package/dist/rng.d.ts +17 -0
- package/dist/rules.d.ts +395 -0
- package/dist/rulesCheck.d.ts +37 -0
- package/dist/runes.d.ts +41 -0
- package/dist/save.d.ts +277 -0
- package/dist/scenario.d.ts +56 -0
- package/dist/scheduler.d.ts +67 -0
- package/dist/score.d.ts +306 -0
- package/dist/sequences.d.ts +132 -0
- package/dist/server/doorman.d.ts +21 -0
- package/dist/server/gameServer.d.ts +346 -0
- package/dist/server/index.d.ts +24 -0
- package/dist/server/lobby.d.ts +1 -0
- package/dist/server/log.d.ts +13 -0
- package/dist/server/match.d.ts +24 -0
- package/dist/server/mode.d.ts +168 -0
- package/dist/server/navigation-worker.d.ts +1 -0
- package/dist/server/navigation.d.ts +11 -0
- package/dist/server/ops.d.ts +78 -0
- package/dist/server/packs.d.ts +15 -0
- package/dist/server/ratings.d.ts +62 -0
- package/dist/server/relay.d.ts +34 -0
- package/dist/server/rooms.d.ts +162 -0
- package/dist/server/seat.d.ts +2 -0
- package/dist/server/seatSettings.d.ts +15 -0
- package/dist/server/secret.d.ts +5 -0
- package/dist/server/start.d.ts +54 -0
- package/dist/server/websocket.d.ts +74 -0
- package/dist/server.js +2861 -0
- package/dist/server.js.map +1 -0
- package/dist/session/can.d.ts +20 -0
- package/dist/session/commandReplay.d.ts +92 -0
- package/dist/session/debug.d.ts +65 -0
- package/dist/session/delay.d.ts +15 -0
- package/dist/session/delta.d.ts +98 -0
- package/dist/session/entityState.d.ts +1 -0
- package/dist/session/local.d.ts +214 -0
- package/dist/session/memory.d.ts +27 -0
- package/dist/session/protocol.d.ts +1155 -0
- package/dist/session/remote.d.ts +530 -0
- package/dist/session/replay.d.ts +114 -0
- package/dist/session/seed.d.ts +7 -0
- package/dist/session/share.d.ts +78 -0
- package/dist/session/socket.d.ts +8 -0
- package/dist/session/wire.d.ts +153 -0
- package/dist/session/worker.d.ts +227 -0
- package/dist/shared.d.ts +171 -0
- package/dist/sides.d.ts +75 -0
- package/dist/sight.d.ts +27 -0
- package/dist/simulate-oXBiw1Ff.js +167 -0
- package/dist/simulate-oXBiw1Ff.js.map +1 -0
- package/dist/simulate-worker.d.ts +1 -0
- package/dist/simulate-worker.js +28 -0
- package/dist/simulate-worker.js.map +1 -0
- package/dist/simulate.d.ts +154 -0
- package/dist/smart.d.ts +9 -0
- package/dist/spatial.d.ts +62 -0
- package/dist/spawning.d.ts +33 -0
- package/dist/stances.d.ts +76 -0
- package/dist/start.d.ts +71 -0
- package/dist/stats.d.ts +122 -0
- package/dist/summons.d.ts +63 -0
- package/dist/systems/abilities.d.ts +3 -0
- package/dist/systems/combat.d.ts +71 -0
- package/dist/systems/combatTold.d.ts +14 -0
- package/dist/systems/construction.d.ts +34 -0
- package/dist/systems/economy.d.ts +21 -0
- package/dist/systems/fog.d.ts +19 -0
- package/dist/systems/life.d.ts +3 -0
- package/dist/systems/movement.d.ts +105 -0
- package/dist/systems/orders.d.ts +7 -0
- package/dist/systems/production.d.ts +49 -0
- package/dist/systems/productionQueue.d.ts +40 -0
- package/dist/systems/victory.d.ts +14 -0
- package/dist/talk.d.ts +65 -0
- package/dist/tech.d.ts +62 -0
- package/dist/techTree.d.ts +38 -0
- package/dist/terrain.d.ts +173 -0
- package/dist/terrainSave.d.ts +29 -0
- package/dist/testing.d.ts +58 -0
- package/dist/testing.js +822 -0
- package/dist/testing.js.map +1 -0
- package/dist/tiles.d.ts +34 -0
- package/dist/timers.d.ts +146 -0
- package/dist/tomes.d.ts +48 -0
- package/dist/trade.d.ts +28 -0
- package/dist/transport.d.ts +76 -0
- package/dist/tunables.d.ts +45 -0
- package/dist/upkeep.d.ts +16 -0
- package/dist/veterancy.d.ts +80 -0
- package/dist/walls.d.ts +86 -0
- package/dist/world.d.ts +1118 -0
- package/package.json +80 -5
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,4701 @@
|
|
|
1
|
+
import { $ as MissingGround, $c as refund, $l as commandHandled, $n as cinematicCommand, $o as REPATH_SPREAD, $r as defineHookType, $t as playerSlot, $u as xpGained, A as drawFromGroup, Aa as bool, Ac as readyAgain, Ad as inventorySlots, Ai as raceOf, Al as replicateEvent, An as logLine, Ar as allowed, As as defaultPickAttack, At as generateTerrain, Au as missileRemoved, B as fieldOptions, Ba as point, Bc as missingTech, Bd as angleTo, Bl as campCleared, Br as boardOrder, Bs as applyBuff, Bt as BLOCKS_WALK, Bu as projectileSpawned, Ca as fitToPlaces, Cc as xpWorth, Cd as innerEffects, Ci as unlockRefusal, Cl as scaledParts, Cn as withData, Co as waitUntil, Cr as lastTeamStanding, Cs as isShunned, Ct as updateMapTerrain, Cu as leftArea, Da as rowsFormation, Dd as passiveEffects, Di as defineRace, Dn as consoleSink, Dr as shareWith, Ds as spotFor, Dt as WATER_DEEP, Du as messageSent, Ea as noneFormation, Ec as queueOf, Ed as noStats, Ei as checkRaces, El as buffEnded, En as Logger, Er as give, Es as shun, Et as Terrain, Eu as manaChanged, F as tableItems, Fa as list, Fc as availableTo, Fd as orderSays, Fi as nearestId, Fl as attackStarted, Fn as dispelBuffs, Fs as rangeAgainst, Ft as LEGACY_TILESET, Fu as pauseAsked, G as mapPlayerData, Ga as defineEffect, Gc as engineMessage, Gd as distToRect, Gi as castTimeOf, Gl as channelEnded, Gn as takeCorpses, Gr as roomIn, Gs as isRooted, Gt as MAP_NEUTRAL, Gu as rustled, H as mapCampData, Ha as seconds, Hc as sellValue, Hd as closestPointOnRect, Hi as footprintCell, Hl as castStarted, Hn as summonEffects, Hr as cargo, Ht as DEFAULT_MAX_PLAYERS, Hu as resourceChanged, I as DEFAULT_HEIGHTS, Ia as number, Ic as availableToPlayer, Id as spawnStart, Ii as canHarvest, Il as boarded, In as dispelEffects, Is as reaches, It as MAX_TILES, Iu as pinged, J as objectFields, Jc as Player, Jd as lerp, Jl as checkpoint, Jn as autocastCommand, Js as isStunned, Jt as MAX_MAP_SIZE, Ju as unitHired, K as mapRegionData, Ka as effectTargets, Kc as toMessage, Kd as hashInts, Ki as declaredRefusal, Kl as channelStarted, Kn as worthless, Kr as shoreNear, Ks as isSilenced, Kt as MAX_LEVEL, Ku as teamChanged, La as object, Lc as chargesLeft, Ld as startingBase, Li as gathering, Ll as bounty, Ln as Path, Lr as BOARD_REACH, Ls as tooNear, Lt as defineTile, Lu as playerDefeated, Ma as ids, Mc as restoreCharges, Mi as setRace, Ml as teamMemory, Mn as rootLogger, Mr as ping, Ms as farthestReach, Mt as mapTileIds, Mu as morphCancelled, N as groupChoices, Na as index, Nc as abilityReach, Nd as defineComponent, Nl as abilityCast, Nn as coreEffects, Nr as pingKinds, Ns as groundAttack, Nt as playTileset, Nu as morphStarted, Oa as squareFormation, Oc as upgradeOf, Od as upgradesOf, Oi as isAvailable, Ol as SessionView, On as createLogRing, Or as CHAT_LENGTH, Os as ATTACK_STATS, Ot as WATER_NONE, Ou as missed, P as mapDrops, Pa as isObject, Pc as abilityRefusal, Pd as snapshotOf, Pi as editDistance, Pl as abilityImpact, Pn as homeOf, Po as plain, Ps as pickAttack, Pu as objectiveChanged, Qc as pay, Qd as vec2, Qi as validTarget, Ql as cinematicEnded, Qn as cinematicChoiceCommand, Qo as ARRIVE_RANGE, Qr as defineHook, Qs as silences, Qt as createMap, Qu as worldFailed, R as campFields, Ra as oneOf, Rc as costRefusal, Rd as TAU, Rl as buffApplied, Rn as corpseSystem, Ro as UPHILL_MISS, Rr as UNLOAD_REACH, Rs as AURA_LINGER, Rt as tileRemap, Ru as playerSurrendered, Sa as coreFormations, Sd as hourOf, Sl as formatNumber, Sn as valueAt, So as waitFor, Ss as isCrowded, St as tilesFor, Su as itemPicked, Ta as formationPoints, Tc as productionQueue, Td as isScenery, Ti as RANDOM_RACE, Tn as LOG_LEVELS, To as defineTimer, Tr as exchange, Ts as roomFor, Tt as profileReach, Tu as lineSaid, U as mapFields, Ua as string, Uc as targetRefusal, Ud as dist, Ul as cellsChanged, Un as summonHooks, Uo as canAttack, Ur as garrisonHooks, Us as hasBuff, Ut as MAP_CREEPS, Uu as resourcesDeposited, Va as rgb, Vc as requirementRefusal, Vd as clamp, Vi as BuildGrid, Vl as campReturned, Vn as illusion, Vo as attackReaches, Vr as canBoard, Vs as avoids, Vt as BRIDGE, Vu as researchFinished, W as mapObjectData, Wa as text, Wc as trainRefusal, Wd as distSq, Wi as castRange, Wl as cellsSeen, Wn as summoned, Wr as loadOrder, Ws as isCrowdControl, Wt as MAP_FORMAT, Wu as resourcesSpent, X as reachedByDefault, Xc as earn, Xd as rotateTowards, Xi as notReady, Xl as cinematicChoice, Xn as cancelTrainCommand, Xr as unloadOrder, Xs as removeBuff, Xt as PLAYER_COLORS, Xu as unloaded, Y as playerFields, Yc as currencyOf, Yd as lerpAngle, Yi as isSpell, Yl as chopped, Yn as cancelConstructionCommand, Yo as fireOnTheMove, Yr as unload, Ys as layerOf, Yt as MIN_MAP_SIZE, Yu as unitTrained, Z as regionFields, Za as startDash, Zc as giveBack, Zd as smoothstep, Zi as targetFacts, Zl as cinematicCued, Zn as castCommand, Zo as shootsUphill, Zs as shieldOf, Zt as RAMP_REACH, Zu as worked, _a as spendCharge, _c as reviveCost, _d as anyEffect, _l as underConstruction, _n as redefineIn, _o as defineSequence, _s as engageLayer, _t as unpackBytes, _u as heroLevelUp, aa as buy, ac as defaultHeroRules, ad as replicatedEvents, af as sin, ai as placementProblem, al as distanceToEdge, an as copyDefinition, ar as orderCommand, as as moveTo, at as groundHash, au as effectStarted, ba as FORMATION_GAP, bd as constructionOf, bi as morph, bl as abilityValues, bn as restoreRedefinitions, br as pausesLeft, bs as engagedWith, bt as mapCell, bu as inventoryChanged, ca as dropOnGround, cc as heroOfState, cd as cooldownOf, ci as siteOf, cl as fighting, cn as definitionIn, co as cueCinematic, cs as setAnchored, ct as savedGround, cu as entityDied, dc as heroesOf, dd as refreshVitals, dl as constructionSite, dn as editableValues, dr as smartCommand, ds as stepAside, dt as terrainPatch, du as entitySpawned, ea as createOrder, el as shortOf, en as COPY_OF, er as coreCommands, es as faceTowards, et as applyTerrainPatch, eu as commandRejected, fa as pickUp, fc as learn, fd as resisted, fn as effectFields, fr as stopCommand, fs as SHUN_TIME, ft as terrainPatchProblem, fu as environmentChanged, ga as shopStock, gc as respawning, gd as aimOf, gn as nested, gr as upgradeBuildingCommand, gs as defaultEngageRoom, gt as packBytes, gu as healed, ha as shopNear, hc as respawnIn, hd as CORE_STAT_KEYS, hi as upgradeLevel, hn as mergeValues, ho as Sequences, hr as trainCommand, hs as defaultEngageLayer, ht as saveTerrain, hu as groundReshaped, ia as atShop, ic as canLearn, if as hypot, ii as placementCells, il as detection, in as blankDefinition, is as makeWay, it as groundDifference, iu as damaged, j as drawFromTable, ja as id, jd as slotsOf, jl as snapshotPart, jn as rootConsole, jr as chat, js as farthestAcquisition, jt as Rng, ju as missileSpawned, ka as Invalid, kd as freeSlot, kn as errorFields, kr as PING_KINDS, ks as attackStat, kt as WATER_SHALLOW, ku as missileHit, la as findItem, lc as heroState, ld as invalidateStats, ll as moving, ln as definitionsIn, lo as playCinematic, lr as rallyCommand, ls as setGhost, lt as startedOn, lu as entityMorphed, ma as sell, mc as maxLevelOf, md as swingTiming, mn as fitValue, ms as bandOf, mt as loadTerrain, mu as gameOver, na as REACH, nc as scaled, nd as defineListener, nf as exactMath, ni as canRepair, nl as Entity, nn as NEW_DEFINITION, ns as isMovingTo, nt as findGround, nu as constructionFinished, oa as createItem, oc as grantXp, od as attackTiming, ol as edgeDistance, on as dataRulesProblem, oo as cinematic, os as moveToEntity, ot as groundOf, ou as effectStopped, pa as priceOf, pc as levelNeeded, pd as statsOf, pn as effectTypesOf, pr as surrenderCommand, ps as attackersOf, pt as withChanges, pu as exchanged, q as mapThings, qi as fire, ql as chatted, qn as attackCommand, qs as isSpellImmune, qt as MAX_MAP_PLAYERS, qu as traded, ra as SHOP_RANGE, rc as HEROES, rd as eventById, rl as deposit, rn as REQUIRED_FIELDS, ro as dashing, rs as lookAhead, rt as groundBase, ru as constructionStarted, sc as heroOf, sd as changedStats, sl as exposure, sn as definitionChanged, so as cinematicCue, sr as pauseCommand, st as isGroundChanges, su as enteredArea, ta as giveOrder, tc as casterFacts, td as defineEvent, tf as cos, ti as canPlaceStructure, tl as spend, tn as DATA_KINDS, tr as defineCommand, ts as halt, tt as cloneTerrain, tu as constructionCancelled, ua as giveItem, ud as mitigation, ui as playerFacts, ul as builtSoFar, un as editableFields, uo as regionCinematic, ur as researchCommand, us as standsInWay, ut as startingTerrain, uu as entityRemoved, va as takeItem, vc as reviveHero, vd as at, vi as meets, vl as STAT_WORDS, vn as redefinitions, vr as askPause, vs as engageWith, vt as adoptTileset, vu as heroRevived, wa as formationContext, wc as productionOf, wd as isNight, wi as unlocked, wl as valueText, wn as withValues, wo as Timers, ws as release, wt as PROFILE_SMOOTH, wu as lineEnded, xa as circleFormation, xd as footprintOf, xi as techOf, xl as describeAbility, xo as wait, xr as surrender, xs as isBlocked, xt as mapTerrain, xu as itemDropped, yc as setHeroLevel, yd as baseStats, yi as missing, yl as abilityText, yn as removeDefinition, ys as engagedLike, yt as blockByDoodads, yu as impact, z as defineMapField, za as optional, zc as gathers, zd as angleDiff, zi as harvesterOf, zl as buffRemoved, zn as corpses, zr as board, zt as BLOCKS_BUILD, zu as projectileRemoved } from "./debugCommands-CzjrSm66.js";
|
|
2
|
+
import { $ as siteWords, A as withConcealment, B as isMadeOf, C as patrolPathOrder, D as viewLevel, E as expose, F as ModError, G as defaultRules, H as satisfies, I as composeGame, J as groundSpeedOfCell, K as resolveRules, L as compositionOf, M as cellsIn, N as defineCellLayer, O as coreHooks, Q as siteKey, R as defineMod, S as followPathOrder, T as EXPOSED_FOR, U as checkDamageTable, V as recompose, W as damageMultiplier, Z as upkeepOf, _ as FogOfWar, a as createWorldFromMap, b as UnlistedError, c as World, d as prefabsFrom, f as spawnPrefab, g as FOG_VISIBLE, h as FOG_UNEXPLORED, i as createWorld, j as cellLayerValues, k as patchAt, l as definePrefab, m as FOG_EXPLORED, n as coreSystems, o as loadWorld, q as groundSpeedAt, r as createRegistry, s as maxPlayersOf, t as assembleGame, u as nestedPartId, v as MAX_FOG_TEAMS, w as SizedNavigation, x as attackPathOrder, y as Registry, z as gameHash } from "./game-DiJEN-Ks.js";
|
|
3
|
+
import { $ as ENTITY_DERIVED, A as mapLanes, At as tunings, B as captureAreaOf, C as LANE_SPAWNER_KIND, Cn as shapeReach, D as heroStrike, Dt as defineTunable, E as defaultWave, Et as spawnCamp, F as lastHitsOf, Ft as setGate, G as capturesOf, Gt as defineStat, H as capturePoint, I as mayDeny, K as capturing, Kt as engineStats, L as withLastHits, Lt as WorldMap, M as wavesSent, Mt as gateChanged, N as withLanes, Nt as gateStates, O as laneMinion, Ot as tunableChanged, P as denying, Pt as isGateOpen, R as CAPTURE_FIELDS, S as LANE_SPAWNER_FIELDS, Sn as shapeBounds, St as camps, T as coreVictory, Tt as respawnCamp, U as captured, V as captureOf, W as captures, Wt as Score, X as Scheduler, Y as PHASES, Yt as scoreData, Zt as scoreOn, _ as pileType, _n as defineArea, _t as campHooks, a as rankOf, b as LANE_END_REACH, bn as shapeAsBox, bt as campNamed, c as withVeterancy, ct as saveWorld, d as stance, dn as talk, dt as SidePaths, en as Objectives, et as ENTITY_SAVE, f as stanceCommand, ft as sideKnowledge, g as pileTaken, h as pileOf, hn as areaShapeOf, ht as DEFAULT_AVOIDANCE, i as grantVeterancy, in as playEffect, it as encodeSave, j as sendWave, jt as withTuning, k as lanesOf, kt as tuneCommand, l as DEFAULT_STANCE, ln as Dialog, m as withStances, mn as Areas, mt as AVOIDANCE_LEVELS, nn as objectiveVictory, nt as SaveError, o as rankedUp, on as stopEffect, ot as saveHeader, p as stanceOf, q as withCapture, qt as presetScore, r as VETERANCY_RANKS, rt as decodeSave, s as veterancy, st as saveProblem, tn as defineObjective, tt as SAVE_FORMAT, u as STANCES, un as LINE_SECONDS, ut as updateSave, v as rubbleType, vn as inShape, vt as campLayout, w as checkLanes, wt as campsOf, x as LANE_SPAWNERS, xn as shapeAt, xt as campRespawnSystem, y as withRemains, yn as shapeArea, yt as campMember, z as CAPTURE_KIND } from "./coreComponents-syuh2fPG.js";
|
|
4
|
+
import { a as navigationData, c as serveNavigationBuilds, i as memoryNavigationCache, o as navigationKey, s as navigationWorker, t as buildNavigationData } from "./builds-C6LfgvEU.js";
|
|
5
|
+
import { a as missionRulesOf, c as tellRules, d as checkMapObjectives, f as giveMapObjective, h as mapObjectivesOf, i as missionRuleState, l as MAP_OBJECTIVES, m as mapObjectives, n as MISSION_RULES, p as mapObjectivePlayers, r as checkMissionRules, s as ruleFired, t as MAP_RULES_KEY, u as MAP_OBJECTIVES_KEY } from "./missionRules-C-bGxm0Q.js";
|
|
6
|
+
import { A as connectAs, C as randomSeed, D as SessionHost, E as RemoteSession, F as readHostMessage, I as readPlayerMessage, M as loopback, N as toRuns, O as SessionRefused, P as isServerMessage, S as tilingNoise, _ as tilesPass, a as packsOf, b as mapCells, c as generateMap, d as resourcesPass, f as roadsPass, g as terrainPass, h as symmetricPlaces, i as packFromJson, j as fromRuns, k as connect, l as nearestDryGround, m as startsPass, n as checkPack, o as basinShape, p as scatterPass, r as checkPackSchema, s as clearGround, t as PackError, u as presetGenerator, v as MAP_RULES, w as delayed, x as layeredNoise, y as checkMap } from "./packs-g5thvmGn.js";
|
|
7
|
+
import { _ as hirerAt, c as makeHero, d as hireCommand, f as learnCommand, g as hireRefusal, h as hire, i as worldFingerprint, l as buyCommand, m as sellCommand, p as reviveCommand, r as saveDifference, s as withHeroes, u as heroCommands } from "./determinism-BCymQwiE.js";
|
|
8
|
+
import { _ as readMessage, d as paceOf, f as savesOf, g as protocolMismatch, h as isFiniteNumber, l as diff, m as isCommand, p as PROTOCOL_VERSION, s as apply, t as LocalSession, u as quantize } from "./local-Dkovq42k.js";
|
|
9
|
+
//#region src/map/schema.ts
|
|
10
|
+
/**
|
|
11
|
+
* Every field of a map, in the order its file has them (`format` before them, what the reader does not know of the
|
|
12
|
+
* file before that). `satisfies` checks that none of `GameMap` is left out
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
var MAP_FIELDS = [
|
|
17
|
+
{
|
|
18
|
+
key: "name",
|
|
19
|
+
kind: "text",
|
|
20
|
+
written: "always",
|
|
21
|
+
empty: () => "Untitled"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: "description",
|
|
25
|
+
kind: "text",
|
|
26
|
+
written: "always",
|
|
27
|
+
empty: () => ""
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "game",
|
|
31
|
+
kind: "text",
|
|
32
|
+
written: "defined"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "width",
|
|
36
|
+
kind: "number",
|
|
37
|
+
written: "always",
|
|
38
|
+
required: true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "depth",
|
|
42
|
+
kind: "number",
|
|
43
|
+
written: "always",
|
|
44
|
+
required: true
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: "cliffHeight",
|
|
48
|
+
kind: "number",
|
|
49
|
+
written: "always",
|
|
50
|
+
empty: () => 3
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "ground",
|
|
54
|
+
kind: "layer",
|
|
55
|
+
written: "always",
|
|
56
|
+
required: true,
|
|
57
|
+
perVertex: true,
|
|
58
|
+
what: "the ground"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "levels",
|
|
62
|
+
kind: "layer",
|
|
63
|
+
written: "always",
|
|
64
|
+
required: true,
|
|
65
|
+
what: "the levels"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "ramps",
|
|
69
|
+
kind: "layer",
|
|
70
|
+
written: "always",
|
|
71
|
+
required: true,
|
|
72
|
+
what: "the ramps"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "water",
|
|
76
|
+
kind: "layer",
|
|
77
|
+
written: "always",
|
|
78
|
+
required: true,
|
|
79
|
+
what: "the water"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "tiles",
|
|
83
|
+
kind: "layer",
|
|
84
|
+
written: "always",
|
|
85
|
+
required: true,
|
|
86
|
+
what: "the paint"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: "cells",
|
|
90
|
+
kind: "layers",
|
|
91
|
+
written: "filled"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "pathing",
|
|
95
|
+
kind: "mask",
|
|
96
|
+
written: "filled",
|
|
97
|
+
what: "what is kept off cells"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: "tileset",
|
|
101
|
+
kind: "names",
|
|
102
|
+
written: "always",
|
|
103
|
+
required: true,
|
|
104
|
+
what: "the tileset"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: "players",
|
|
108
|
+
kind: "list",
|
|
109
|
+
written: "always",
|
|
110
|
+
required: true,
|
|
111
|
+
byId: true
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: "objects",
|
|
115
|
+
kind: "list",
|
|
116
|
+
written: "always",
|
|
117
|
+
required: true,
|
|
118
|
+
byId: true
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: "camps",
|
|
122
|
+
kind: "list",
|
|
123
|
+
written: "always",
|
|
124
|
+
required: true,
|
|
125
|
+
byId: true
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "regions",
|
|
129
|
+
kind: "list",
|
|
130
|
+
written: "filled",
|
|
131
|
+
byId: true,
|
|
132
|
+
empty: () => []
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "points",
|
|
136
|
+
kind: "list",
|
|
137
|
+
written: "filled",
|
|
138
|
+
byId: true,
|
|
139
|
+
empty: () => []
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "paths",
|
|
143
|
+
kind: "list",
|
|
144
|
+
written: "filled",
|
|
145
|
+
byId: true,
|
|
146
|
+
empty: () => []
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "doodads",
|
|
150
|
+
kind: "list",
|
|
151
|
+
written: "filled",
|
|
152
|
+
byId: true,
|
|
153
|
+
empty: () => []
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: "cameras",
|
|
157
|
+
kind: "list",
|
|
158
|
+
written: "filled",
|
|
159
|
+
byId: true,
|
|
160
|
+
empty: () => []
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: "randomGroups",
|
|
164
|
+
kind: "list",
|
|
165
|
+
written: "filled"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
key: "itemTables",
|
|
169
|
+
kind: "list",
|
|
170
|
+
written: "filled"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
key: "forces",
|
|
174
|
+
kind: "list",
|
|
175
|
+
written: "filled"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
key: "races",
|
|
179
|
+
kind: "names",
|
|
180
|
+
written: "filled"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: "author",
|
|
184
|
+
kind: "text",
|
|
185
|
+
written: "filled"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: "silence",
|
|
189
|
+
kind: "names",
|
|
190
|
+
written: "filled"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: "meantFor",
|
|
194
|
+
kind: "text",
|
|
195
|
+
written: "filled"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: "explored",
|
|
199
|
+
kind: "boolean",
|
|
200
|
+
written: "defined"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
key: "loading",
|
|
204
|
+
kind: "text",
|
|
205
|
+
written: "filled"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
key: "preview",
|
|
209
|
+
kind: "text",
|
|
210
|
+
written: "filled"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
key: "environment",
|
|
214
|
+
kind: "object",
|
|
215
|
+
written: "filled"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: "definitions",
|
|
219
|
+
kind: "object",
|
|
220
|
+
written: "filled",
|
|
221
|
+
plain: true
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: "generated",
|
|
225
|
+
kind: "object",
|
|
226
|
+
written: "defined"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
key: "day",
|
|
230
|
+
kind: "object",
|
|
231
|
+
written: "filled"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
key: "bounds",
|
|
235
|
+
kind: "object",
|
|
236
|
+
written: "defined"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
key: "data",
|
|
240
|
+
kind: "object",
|
|
241
|
+
written: "filled",
|
|
242
|
+
empty: () => ({}),
|
|
243
|
+
plain: true
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
key: "editor",
|
|
247
|
+
kind: "object",
|
|
248
|
+
written: "filled",
|
|
249
|
+
plain: true
|
|
250
|
+
}
|
|
251
|
+
];
|
|
252
|
+
/** The fields as specs, for what walks them */
|
|
253
|
+
var MAP_FIELD_SPECS = MAP_FIELDS;
|
|
254
|
+
/** What a reader of this format knows in a file: `format` and every field. The rest is carried along (`GameMap.extra`) */
|
|
255
|
+
var KNOWN = /* @__PURE__ */ new Set(["format", ...MAP_FIELDS.map((field) => field.key)]);
|
|
256
|
+
/** The fields of a kind */
|
|
257
|
+
var fieldsOf = (...kinds) => MAP_FIELD_SPECS.filter((field) => kinds.includes(field.kind));
|
|
258
|
+
/** The lists of things with ids: merged thing by thing (`mergeMaps`), an entry to a line (`mapText`) */
|
|
259
|
+
var BY_ID$1 = MAP_FIELD_SPECS.filter((field) => field.byId).map((field) => field.key);
|
|
260
|
+
/** The layers of the ground every map has: merged cell by cell (`mergeMaps`), a row to a line (`mapText`) */
|
|
261
|
+
var LAYERS$1 = fieldsOf("layer").map((field) => field.key);
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/map/file.ts
|
|
264
|
+
var isObject$1 = (value) => typeof value === "object" && value !== null;
|
|
265
|
+
/** The steps from each format to the one after it, by the format they start from */
|
|
266
|
+
var MIGRATIONS = {
|
|
267
|
+
1: (file) => ({
|
|
268
|
+
...file,
|
|
269
|
+
tileset: [...LEGACY_TILESET]
|
|
270
|
+
}),
|
|
271
|
+
2: (file) => {
|
|
272
|
+
const width = Number(file.width);
|
|
273
|
+
const depth = Number(file.depth);
|
|
274
|
+
const split = (key, count, bytes) => {
|
|
275
|
+
const packed = file[key];
|
|
276
|
+
const whole = typeof packed === "string" ? unpackBytes(packed, count * bytes) : void 0;
|
|
277
|
+
return whole ? rowsOf(whole, bytes) : packed;
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
...file,
|
|
281
|
+
ground: split("ground", depth + 1, (width + 1) * 4),
|
|
282
|
+
levels: split("levels", depth, width),
|
|
283
|
+
ramps: split("ramps", depth, width),
|
|
284
|
+
water: split("water", depth, width),
|
|
285
|
+
tiles: split("tiles", depth, width)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
/** The layers of cells of a map with anything in them, as the file has them: none when there are none */
|
|
290
|
+
function mapCells$1(map) {
|
|
291
|
+
const layers = Object.entries(map.cells ?? {}).filter(([, cells]) => cells.some((value) => value !== 0));
|
|
292
|
+
return layers.length ? Object.fromEntries(layers.sort(([a], [b]) => a < b ? -1 : 1).map(([id, cells]) => [id, rowsOf(cells, map.width)])) : void 0;
|
|
293
|
+
}
|
|
294
|
+
/** Bytes as a line of text for each row of `bytes` of them */
|
|
295
|
+
function rowsOf(bytes, row) {
|
|
296
|
+
const lines = [];
|
|
297
|
+
for (let at = 0; at < bytes.length; at += row) lines.push(pack(bytes.subarray(at, at + row)));
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* A map file brought up to the format this reader knows, step by step. Throws for what is not a map file, and for
|
|
302
|
+
* one of a newer format: what changed in it cannot be known here. `to` and `steps` are the reader's own when left out
|
|
303
|
+
*
|
|
304
|
+
* @beta
|
|
305
|
+
*/
|
|
306
|
+
function migrateMap(data, to = 3, steps = MIGRATIONS) {
|
|
307
|
+
if (!isObject$1(data)) throw new Error("Map file: not a map");
|
|
308
|
+
let file = data;
|
|
309
|
+
const format = file.format;
|
|
310
|
+
if (typeof format !== "number" || !Number.isInteger(format) || format < 1) throw new Error(`Map file: format ${String(format)} is not one this game reads`);
|
|
311
|
+
if (format > to) throw new Error(`Map file: format ${format} is newer than this game reads (${to}): it was saved by a newer version of the editor`);
|
|
312
|
+
for (let from = format; from < to; from++) {
|
|
313
|
+
const step = steps[from];
|
|
314
|
+
if (!step) throw new Error(`Map file: there is no way from format ${from} to ${from + 1}`);
|
|
315
|
+
file = {
|
|
316
|
+
...step(file),
|
|
317
|
+
format: from + 1
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return file;
|
|
321
|
+
}
|
|
322
|
+
var pack = packBytes;
|
|
323
|
+
function unpack(text, length, what) {
|
|
324
|
+
const bytes = unpackBytes(text, length);
|
|
325
|
+
if (!bytes) throw new Error(`Map file: ${what} does not match the size of the map`);
|
|
326
|
+
return bytes;
|
|
327
|
+
}
|
|
328
|
+
/** Whether a value holds anything: text not empty, a list or an object with something in it, a layer with a cell set */
|
|
329
|
+
function filled(value) {
|
|
330
|
+
if (value === void 0 || value === null || value === "" || value === false) return false;
|
|
331
|
+
if (value instanceof Uint8Array) return value.some((each) => each !== 0);
|
|
332
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
333
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
/** The bytes of a layer, and how many to a row: the heights by corner (four bytes each), the rest by cell */
|
|
337
|
+
function layerBytes(map, field, layer) {
|
|
338
|
+
return field.perVertex ? {
|
|
339
|
+
bytes: new Uint8Array(layer.buffer, layer.byteOffset, layer.byteLength),
|
|
340
|
+
row: (map.width + 1) * 4
|
|
341
|
+
} : {
|
|
342
|
+
bytes: layer,
|
|
343
|
+
row: map.width
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** A field as its file has it, or none where the file leaves it out (`MapFieldSpec.written`) */
|
|
347
|
+
function written(map, field) {
|
|
348
|
+
const value = map[field.key];
|
|
349
|
+
if (field.written === "defined" && value === void 0) return void 0;
|
|
350
|
+
if (field.kind === "layers") return mapCells$1(map);
|
|
351
|
+
if (field.written === "filled" && !filled(value)) return void 0;
|
|
352
|
+
switch (field.kind) {
|
|
353
|
+
case "layer":
|
|
354
|
+
case "mask": {
|
|
355
|
+
const { bytes, row } = layerBytes(map, field, value);
|
|
356
|
+
return rowsOf(bytes, row);
|
|
357
|
+
}
|
|
358
|
+
case "names": return field.written === "filled" ? [...value] : value;
|
|
359
|
+
case "object": return field.plain ? plain(value, field.key, "Map", "keep numbers, text, lists and plain objects with a map") : value;
|
|
360
|
+
default: return value;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* A map as its file. Throws when what the game keeps with it (`data`) is not plain data, saying where
|
|
365
|
+
*
|
|
366
|
+
* @beta
|
|
367
|
+
*/
|
|
368
|
+
function serializeMap(map) {
|
|
369
|
+
const file = {
|
|
370
|
+
...map.extra,
|
|
371
|
+
format: 3
|
|
372
|
+
};
|
|
373
|
+
for (const field of MAP_FIELD_SPECS) {
|
|
374
|
+
const value = written(map, field);
|
|
375
|
+
if (value === void 0 || field.kind === "object" && field.written === "filled" && !filled(value)) continue;
|
|
376
|
+
file[field.key] = value;
|
|
377
|
+
}
|
|
378
|
+
return file;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The maps of map files, by the name of each file without its folder and `.vgmap.json`: `./maps/field.vgmap.json` is
|
|
382
|
+
* `field`. The files by their paths, read (`parseMap`): imported one by one, or Vite's
|
|
383
|
+
* `import.meta.glob('./maps/*.vgmap.json', { eager: true, import: 'default' })` where only a page reads them
|
|
384
|
+
*
|
|
385
|
+
* export const maps = mapsFrom({ './maps/field.vgmap.json': field, './maps/hills.vgmap.json': hills })
|
|
386
|
+
*
|
|
387
|
+
* Throws for two files of one name, and with the path of a file that cannot be read
|
|
388
|
+
*
|
|
389
|
+
* @beta
|
|
390
|
+
*/
|
|
391
|
+
function mapsFrom(files) {
|
|
392
|
+
const maps = {};
|
|
393
|
+
for (const [path, file] of Object.entries(files)) {
|
|
394
|
+
const name = (path.split("/").pop() ?? path).replace(/\.vgmap\.json$|\.json$/, "");
|
|
395
|
+
if (name in maps) throw new Error(`mapsFrom: two files are named "${name}"`);
|
|
396
|
+
try {
|
|
397
|
+
maps[name] = parseMap(file);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new Error(`${path}: ${error.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return maps;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Reads a map file, of this format or an older one. Throws with what is wrong with it
|
|
406
|
+
*
|
|
407
|
+
* @beta
|
|
408
|
+
*/
|
|
409
|
+
function parseMap(file) {
|
|
410
|
+
const data = migrateMap(file);
|
|
411
|
+
const width = Number(data.width);
|
|
412
|
+
const depth = Number(data.depth);
|
|
413
|
+
const inRange = (size) => Number.isInteger(size) && size >= 8 && size <= 512;
|
|
414
|
+
if (!inRange(width) || !inRange(depth)) throw new Error(`Map file: the size is out of range, between 8 and 512 cells each way`);
|
|
415
|
+
/** A layer of the ground as its rows have it, back in one piece: `bytes` to a row */
|
|
416
|
+
const rows = (lines, count, bytes, what) => {
|
|
417
|
+
if (!Array.isArray(lines) || lines.length !== count || lines.some((line) => typeof line !== "string")) throw new Error(`Map file: ${what} does not match the size of the map`);
|
|
418
|
+
const whole = new Uint8Array(count * bytes);
|
|
419
|
+
lines.forEach((line, row) => whole.set(unpack(line, bytes, what), row * bytes));
|
|
420
|
+
return whole;
|
|
421
|
+
};
|
|
422
|
+
const layer = (field, lines, what = field.what ?? field.key) => field.perVertex ? new Float32Array(rows(lines, depth + 1, (width + 1) * 4, what).buffer) : rows(lines, depth, width, what);
|
|
423
|
+
/** A list's entries, each an object, with an id where the list is of things with ids (`byId`) */
|
|
424
|
+
const entries = (field, list) => {
|
|
425
|
+
list.forEach((entry, at) => {
|
|
426
|
+
if (!isObject$1(entry) || Array.isArray(entry)) throw new Error(`Map file: ${field.key}[${at}] is not an object`);
|
|
427
|
+
if (field.byId && !Number.isFinite(entry.id)) throw new Error(`Map file: ${field.key}[${at}] has no id`);
|
|
428
|
+
});
|
|
429
|
+
return list;
|
|
430
|
+
};
|
|
431
|
+
const map = {};
|
|
432
|
+
for (const field of MAP_FIELD_SPECS) {
|
|
433
|
+
const value = data[field.key];
|
|
434
|
+
let read;
|
|
435
|
+
switch (field.kind) {
|
|
436
|
+
case "text":
|
|
437
|
+
read = typeof value === "string" ? value : void 0;
|
|
438
|
+
break;
|
|
439
|
+
case "number":
|
|
440
|
+
read = field.key === "width" ? width : field.key === "depth" ? depth : Number(value) || void 0;
|
|
441
|
+
break;
|
|
442
|
+
case "boolean":
|
|
443
|
+
read = typeof value === "boolean" ? value : void 0;
|
|
444
|
+
break;
|
|
445
|
+
case "layer":
|
|
446
|
+
read = layer(field, value);
|
|
447
|
+
break;
|
|
448
|
+
case "mask":
|
|
449
|
+
read = value === void 0 ? void 0 : layer(field, value);
|
|
450
|
+
break;
|
|
451
|
+
case "layers":
|
|
452
|
+
read = isObject$1(value) ? Object.fromEntries(Object.keys(value).map((id) => [id, layer(field, value[id], `the cells of ${id}`)])) : void 0;
|
|
453
|
+
break;
|
|
454
|
+
case "names":
|
|
455
|
+
if (field.required) {
|
|
456
|
+
if (!Array.isArray(value)) throw new Error(`Map file: ${field.key} is missing`);
|
|
457
|
+
if (value.length > 255 || value.some((id) => typeof id !== "string")) throw new Error("Map file: the tileset is not a list of tile names");
|
|
458
|
+
read = value;
|
|
459
|
+
} else read = Array.isArray(value) ? value.filter((name) => typeof name === "string") : void 0;
|
|
460
|
+
break;
|
|
461
|
+
case "list":
|
|
462
|
+
if (field.required && !Array.isArray(value)) throw new Error(`Map file: ${field.key} is missing`);
|
|
463
|
+
read = Array.isArray(value) ? entries(field, value) : void 0;
|
|
464
|
+
break;
|
|
465
|
+
case "object": read = isObject$1(value) && !(field.plain && Array.isArray(value)) ? value : void 0;
|
|
466
|
+
}
|
|
467
|
+
if (read === void 0) read = field.empty?.();
|
|
468
|
+
if (read !== void 0) map[field.key] = read;
|
|
469
|
+
}
|
|
470
|
+
map.objects = map.objects.map((object) => ({
|
|
471
|
+
...object,
|
|
472
|
+
scale: object.scale ?? 1
|
|
473
|
+
}));
|
|
474
|
+
const extra = Object.fromEntries(Object.entries(data).filter(([key]) => !KNOWN.has(key)));
|
|
475
|
+
if (Object.keys(extra).length > 0) map.extra = extra;
|
|
476
|
+
return map;
|
|
477
|
+
}
|
|
478
|
+
/** A copy of a field's value that shares nothing with it */
|
|
479
|
+
function copied(field, value) {
|
|
480
|
+
switch (field.kind) {
|
|
481
|
+
case "layer":
|
|
482
|
+
case "mask": return value.slice();
|
|
483
|
+
case "layers": return Object.fromEntries(Object.entries(value).map(([id, cells]) => [id, cells.slice()]));
|
|
484
|
+
case "names": return [...value];
|
|
485
|
+
case "list":
|
|
486
|
+
case "object": return structuredClone(value);
|
|
487
|
+
default: return value;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* A copy that shares nothing with the original
|
|
492
|
+
*
|
|
493
|
+
* @beta
|
|
494
|
+
*/
|
|
495
|
+
function cloneMap(map) {
|
|
496
|
+
const copy = { ...map };
|
|
497
|
+
for (const field of MAP_FIELD_SPECS) {
|
|
498
|
+
const value = map[field.key];
|
|
499
|
+
if (value !== void 0) copy[field.key] = copied(field, value);
|
|
500
|
+
}
|
|
501
|
+
return copy;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The map without what its editor keeps of its own (`GameMap.editor`): what a game is given to play. The very map
|
|
505
|
+
* where it has none
|
|
506
|
+
*
|
|
507
|
+
* @beta
|
|
508
|
+
*/
|
|
509
|
+
function withoutEditor(map) {
|
|
510
|
+
if (!map.editor) return map;
|
|
511
|
+
const { editor: _, ...rest } = map;
|
|
512
|
+
return rest;
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/map/resize.ts
|
|
516
|
+
/**
|
|
517
|
+
* A map grown or cut on any side: the ground, cliffs, water and paint go on as they were at the old edge where it
|
|
518
|
+
* grows; everything on it moves along, and what is no longer on the map is gone. The map itself is left as it was.
|
|
519
|
+
* What the engine keeps moves (objects, camps, regions, points, paths, doodads, cameras, the players' starts, the bounds,
|
|
520
|
+
* the game's own kinds' things in `data`); what others keep moves with the `resizers` given (the client's looks:
|
|
521
|
+
* `mapLooksResizer`)
|
|
522
|
+
*
|
|
523
|
+
* @beta
|
|
524
|
+
*/
|
|
525
|
+
function resizeMap(map, sides, resizers = []) {
|
|
526
|
+
const { left, right, top, bottom } = sides;
|
|
527
|
+
const width = map.width + left + right;
|
|
528
|
+
const depth = map.depth + top + bottom;
|
|
529
|
+
if (width < 8 || depth < 8 || width > 512 || depth > 512) throw new Error(`A map is between 8 and 512 cells each way, not ${width} × ${depth}`);
|
|
530
|
+
const copy = cloneMap(map);
|
|
531
|
+
const layer = (old, oldRow, oldColumn, row, column) => {
|
|
532
|
+
const out = new old.constructor(row * column);
|
|
533
|
+
for (let z = 0; z < column; z++) {
|
|
534
|
+
const from = clamp(z - top, 0, oldColumn - 1) * oldRow;
|
|
535
|
+
for (let x = 0; x < row; x++) out[z * row + x] = old[from + clamp(x - left, 0, oldRow - 1)];
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
};
|
|
539
|
+
copy.width = width;
|
|
540
|
+
copy.depth = depth;
|
|
541
|
+
const grown = (old, perVertex = false) => perVertex ? layer(old, map.width + 1, map.depth + 1, width + 1, depth + 1) : layer(old, map.width, map.depth, width, depth);
|
|
542
|
+
const into = copy;
|
|
543
|
+
for (const field of fieldsOf("layer", "mask", "layers")) {
|
|
544
|
+
const value = map[field.key];
|
|
545
|
+
if (!value) continue;
|
|
546
|
+
into[field.key] = field.kind === "layers" ? Object.fromEntries(Object.entries(value).map(([id, cells]) => [id, grown(cells)])) : grown(value, field.perVertex);
|
|
547
|
+
}
|
|
548
|
+
const on = (p) => p.x >= 0 && p.z >= 0 && p.x <= width && p.z <= depth;
|
|
549
|
+
const moved = (p) => ({
|
|
550
|
+
...p,
|
|
551
|
+
x: p.x + left,
|
|
552
|
+
z: p.z + top
|
|
553
|
+
});
|
|
554
|
+
const inside = (p) => ({
|
|
555
|
+
...p,
|
|
556
|
+
x: clamp(p.x, 0, width),
|
|
557
|
+
z: clamp(p.z, 0, depth)
|
|
558
|
+
});
|
|
559
|
+
const shape = (shape) => {
|
|
560
|
+
if (shape.kind !== "rect") {
|
|
561
|
+
const center = moved(shape);
|
|
562
|
+
return on(center) ? center : void 0;
|
|
563
|
+
}
|
|
564
|
+
const x0 = Math.max(shape.x - shape.width / 2 + left, 0);
|
|
565
|
+
const z0 = Math.max(shape.z - shape.depth / 2 + top, 0);
|
|
566
|
+
const x1 = Math.min(shape.x + shape.width / 2 + left, width);
|
|
567
|
+
const z1 = Math.min(shape.z + shape.depth / 2 + top, depth);
|
|
568
|
+
return x1 > x0 && z1 > z0 ? {
|
|
569
|
+
...shape,
|
|
570
|
+
x: (x0 + x1) / 2,
|
|
571
|
+
z: (z0 + z1) / 2,
|
|
572
|
+
width: x1 - x0,
|
|
573
|
+
depth: z1 - z0
|
|
574
|
+
} : void 0;
|
|
575
|
+
};
|
|
576
|
+
copy.objects = copy.objects.map((object) => ({
|
|
577
|
+
...object,
|
|
578
|
+
...moved(object)
|
|
579
|
+
})).filter(on);
|
|
580
|
+
copy.camps = copy.camps.map((camp) => ({
|
|
581
|
+
...camp,
|
|
582
|
+
...moved(camp)
|
|
583
|
+
})).filter(on);
|
|
584
|
+
copy.points = copy.points.map((point) => ({
|
|
585
|
+
...point,
|
|
586
|
+
...moved(point)
|
|
587
|
+
})).filter(on);
|
|
588
|
+
copy.doodads = copy.doodads.map((doodad) => ({
|
|
589
|
+
...doodad,
|
|
590
|
+
...moved(doodad)
|
|
591
|
+
})).filter(on);
|
|
592
|
+
copy.cameras = copy.cameras.map((camera) => ({
|
|
593
|
+
...camera,
|
|
594
|
+
...inside(moved(camera))
|
|
595
|
+
}));
|
|
596
|
+
copy.paths = copy.paths.map((path) => ({
|
|
597
|
+
...path,
|
|
598
|
+
points: path.points.map((p) => inside(moved(p)))
|
|
599
|
+
}));
|
|
600
|
+
copy.players = copy.players.map((player) => {
|
|
601
|
+
if (!player.start) return player;
|
|
602
|
+
const start = moved(player.start);
|
|
603
|
+
const { start: _, ...rest } = player;
|
|
604
|
+
return on(start) ? {
|
|
605
|
+
...player,
|
|
606
|
+
start
|
|
607
|
+
} : rest;
|
|
608
|
+
});
|
|
609
|
+
copy.regions = copy.regions.flatMap((region) => {
|
|
610
|
+
const moved = shape(region.shape);
|
|
611
|
+
return moved ? [{
|
|
612
|
+
...region,
|
|
613
|
+
shape: moved
|
|
614
|
+
}] : [];
|
|
615
|
+
});
|
|
616
|
+
for (const [kind, kept] of Object.entries(copy.data)) if (Array.isArray(kept) && kept.length && kept.every(isThing)) copy.data[kind] = kept.map((thing) => moved(thing)).filter(on);
|
|
617
|
+
if (map.bounds) {
|
|
618
|
+
const x = Math.max(map.bounds.x + left, 0);
|
|
619
|
+
const z = Math.max(map.bounds.z + top, 0);
|
|
620
|
+
const bounds = {
|
|
621
|
+
x,
|
|
622
|
+
z,
|
|
623
|
+
width: Math.min(map.bounds.x + left + map.bounds.width, width) - x,
|
|
624
|
+
depth: Math.min(map.bounds.z + top + map.bounds.depth, depth) - z
|
|
625
|
+
};
|
|
626
|
+
if (bounds.width > 0 && bounds.depth > 0) copy.bounds = bounds;
|
|
627
|
+
else delete copy.bounds;
|
|
628
|
+
}
|
|
629
|
+
const resize = {
|
|
630
|
+
sides,
|
|
631
|
+
width,
|
|
632
|
+
depth,
|
|
633
|
+
moved,
|
|
634
|
+
on,
|
|
635
|
+
inside,
|
|
636
|
+
shape
|
|
637
|
+
};
|
|
638
|
+
for (const resizer of resizers) resizer(copy, resize);
|
|
639
|
+
return copy;
|
|
640
|
+
}
|
|
641
|
+
/** A thing of a game's own kind, as `mapThings` reads one: an id, and a place */
|
|
642
|
+
var isThing = (thing) => typeof thing === "object" && thing !== null && Number.isInteger(thing.id) && Number.isFinite(thing.x) && Number.isFinite(thing.z);
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/map/images.ts
|
|
645
|
+
/**
|
|
646
|
+
* Heights from the pixels of a picture (RGBA, as a canvas gives them): dark is `low`, light is `high`, the picture
|
|
647
|
+
* stretched over the corners of the map's ground. A heightmap made elsewhere, read into a map
|
|
648
|
+
*
|
|
649
|
+
* @beta
|
|
650
|
+
*/
|
|
651
|
+
function heightsFromPixels(pixels, imageWidth, imageHeight, map, low, high) {
|
|
652
|
+
const row = map.width + 1;
|
|
653
|
+
const ground = new Float32Array(row * (map.depth + 1));
|
|
654
|
+
const at = (x, y) => {
|
|
655
|
+
const i = (clamp(y, 0, imageHeight - 1) * imageWidth + clamp(x, 0, imageWidth - 1)) * 4;
|
|
656
|
+
return (pixels[i] * .299 + pixels[i + 1] * .587 + pixels[i + 2] * .114) / 255;
|
|
657
|
+
};
|
|
658
|
+
for (let iz = 0; iz <= map.depth; iz++) for (let ix = 0; ix <= map.width; ix++) {
|
|
659
|
+
const fx = ix / map.width * (imageWidth - 1);
|
|
660
|
+
const fy = iz / map.depth * (imageHeight - 1);
|
|
661
|
+
const x = Math.floor(fx);
|
|
662
|
+
const y = Math.floor(fy);
|
|
663
|
+
const tx = fx - x;
|
|
664
|
+
const ty = fy - y;
|
|
665
|
+
const top = at(x, y) * (1 - tx) + at(x + 1, y) * tx;
|
|
666
|
+
const bottom = at(x, y + 1) * (1 - tx) + at(x + 1, y + 1) * tx;
|
|
667
|
+
ground[iz * row + ix] = low + (top * (1 - ty) + bottom * ty) * (high - low);
|
|
668
|
+
}
|
|
669
|
+
return ground;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The heights of a terrain as the pixels of a picture (RGBA), a pixel a corner: the lowest black, the highest white
|
|
673
|
+
*
|
|
674
|
+
* @beta
|
|
675
|
+
*/
|
|
676
|
+
function heightsToPixels(heights, width, depth) {
|
|
677
|
+
let low = Infinity;
|
|
678
|
+
let high = -Infinity;
|
|
679
|
+
for (const h of heights) {
|
|
680
|
+
low = Math.min(low, h);
|
|
681
|
+
high = Math.max(high, h);
|
|
682
|
+
}
|
|
683
|
+
const span = high - low || 1;
|
|
684
|
+
const pixels = new Uint8ClampedArray((width + 1) * (depth + 1) * 4);
|
|
685
|
+
for (let i = 0; i < heights.length; i++) {
|
|
686
|
+
const value = Math.round((heights[i] - low) / span * 255);
|
|
687
|
+
pixels.set([
|
|
688
|
+
value,
|
|
689
|
+
value,
|
|
690
|
+
value,
|
|
691
|
+
255
|
|
692
|
+
], i * 4);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
pixels,
|
|
696
|
+
low,
|
|
697
|
+
high
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/mapFile.ts
|
|
702
|
+
/** The order the parts of a map come in in its file. What is not listed comes after, by name */
|
|
703
|
+
var PARTS = [
|
|
704
|
+
"format",
|
|
705
|
+
"name",
|
|
706
|
+
"description",
|
|
707
|
+
"author",
|
|
708
|
+
"meantFor",
|
|
709
|
+
"loading",
|
|
710
|
+
"explored",
|
|
711
|
+
"silence",
|
|
712
|
+
"game",
|
|
713
|
+
"width",
|
|
714
|
+
"depth",
|
|
715
|
+
"cliffHeight",
|
|
716
|
+
"data",
|
|
717
|
+
"editor",
|
|
718
|
+
"players",
|
|
719
|
+
"regions",
|
|
720
|
+
"points",
|
|
721
|
+
"paths",
|
|
722
|
+
"doodads",
|
|
723
|
+
"cameras",
|
|
724
|
+
"randomGroups",
|
|
725
|
+
"itemTables",
|
|
726
|
+
"definitions",
|
|
727
|
+
"forces",
|
|
728
|
+
"races",
|
|
729
|
+
"environment",
|
|
730
|
+
"day",
|
|
731
|
+
"camps",
|
|
732
|
+
"objects",
|
|
733
|
+
"tileset",
|
|
734
|
+
"preview"
|
|
735
|
+
];
|
|
736
|
+
/** The layers come last: lines nobody reads, a row of the ground to each (the map's table says which: `MAP_FIELDS`) */
|
|
737
|
+
var LAYERS = [...fieldsOf("layer", "layers").map((field) => field.key)];
|
|
738
|
+
/** Lists with one entry to a line, in the order of their ids */
|
|
739
|
+
var LISTS = new Set(BY_ID$1);
|
|
740
|
+
/** The order of what an entry holds. What is not listed comes after, by name */
|
|
741
|
+
var FIELDS = [
|
|
742
|
+
"id",
|
|
743
|
+
"name",
|
|
744
|
+
"type",
|
|
745
|
+
"owner",
|
|
746
|
+
"team",
|
|
747
|
+
"color",
|
|
748
|
+
"race",
|
|
749
|
+
"start",
|
|
750
|
+
"x",
|
|
751
|
+
"z",
|
|
752
|
+
"facing",
|
|
753
|
+
"scale",
|
|
754
|
+
"look",
|
|
755
|
+
"units",
|
|
756
|
+
"shape",
|
|
757
|
+
"points",
|
|
758
|
+
"width",
|
|
759
|
+
"paint",
|
|
760
|
+
"keepClear",
|
|
761
|
+
"profile",
|
|
762
|
+
"health",
|
|
763
|
+
"mana",
|
|
764
|
+
"level",
|
|
765
|
+
"abilities",
|
|
766
|
+
"items",
|
|
767
|
+
"amount",
|
|
768
|
+
"underConstruction",
|
|
769
|
+
"lifetime",
|
|
770
|
+
"orders",
|
|
771
|
+
"data"
|
|
772
|
+
];
|
|
773
|
+
/** The keys of something in a set order: those listed first, in that order, then the rest by name */
|
|
774
|
+
function inOrder(value, first) {
|
|
775
|
+
const keys = Object.keys(value);
|
|
776
|
+
return [...first.filter((key) => keys.includes(key)), ...keys.filter((key) => !first.includes(key)).sort()];
|
|
777
|
+
}
|
|
778
|
+
var entryText = (entry) => `{ ${inOrder(entry, FIELDS).filter((key) => entry[key] !== void 0).map((key) => `${JSON.stringify(key)}: ${JSON.stringify(entry[key])}`).join(", ")} }`;
|
|
779
|
+
/**
|
|
780
|
+
* A map as the text of its file, written to be read by a person comparing two versions of it: every part on a line
|
|
781
|
+
* of its own in a set order, one object to a line in the order of their ids with what each holds in a set order
|
|
782
|
+
* too, and the layers last. Moving one object changes one line, whatever was done before
|
|
783
|
+
*
|
|
784
|
+
* @beta
|
|
785
|
+
*/
|
|
786
|
+
function mapText(map) {
|
|
787
|
+
const file = serializeMap(map);
|
|
788
|
+
return `{\n${inOrder(file, [...PARTS, ...LAYERS]).sort((a, b) => Number(LAYERS.includes(a)) - Number(LAYERS.includes(b))).map((key) => {
|
|
789
|
+
const value = file[key];
|
|
790
|
+
if (key === "cells" && value && typeof value === "object") return `\t"cells": {\n${Object.entries(value).map(([id, rows]) => `\t\t${JSON.stringify(id)}: [\n${rows.map((row) => `\t\t\t${JSON.stringify(row)}`).join(",\n")}\n\t\t]`).join(",\n")}\n\t}`;
|
|
791
|
+
if (LAYERS.includes(key) && Array.isArray(value)) return `\t${JSON.stringify(key)}: [\n${value.map((row) => `\t\t${JSON.stringify(row)}`).join(",\n")}\n\t]`;
|
|
792
|
+
if (!LISTS.has(key) || !Array.isArray(value)) return `\t${JSON.stringify(key)}: ${JSON.stringify(value)}`;
|
|
793
|
+
if (!value.length) return `\t${JSON.stringify(key)}: []`;
|
|
794
|
+
const entries = [...value].sort((a, b) => a.id - b.id);
|
|
795
|
+
return `\t${JSON.stringify(key)}: [\n${entries.map((entry) => `\t\t${entryText(entry)}`).join(",\n")}\n\t]`;
|
|
796
|
+
}).join(",\n")}\n}\n`;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* What a map file is called, without what comes after the first dot: the name of the map in small letters, with a
|
|
800
|
+
* dash for whatever is not a letter or a number. `The Pass!` is `the-pass`
|
|
801
|
+
*
|
|
802
|
+
* @beta
|
|
803
|
+
*/
|
|
804
|
+
function mapFileStem(name) {
|
|
805
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "map";
|
|
806
|
+
}
|
|
807
|
+
/** A stem as the start of the name of a type: `the-pass` is `ThePass` */
|
|
808
|
+
var typeName = (stem) => {
|
|
809
|
+
const name = stem.replace(/(^|-)([a-z0-9])/g, (_, __, letter) => letter.toUpperCase());
|
|
810
|
+
return /^[0-9]/.test(name) ? `Map${name}` : name;
|
|
811
|
+
};
|
|
812
|
+
/** A name as TypeScript writes text, in single quotes as the rest of the code has it. Whatever is in it stays inside */
|
|
813
|
+
var quoted = (name) => `'${JSON.stringify(name).slice(1, -1).replace(/\\"/g, "\"").replace(/'/g, "\\'")}'`;
|
|
814
|
+
/** The names as a type: one to a line, so that naming one more thing changes one line */
|
|
815
|
+
var union = (names) => names.length ? [...names].sort().map((name) => `\n\t| ${quoted(name)}`).join("") : " never";
|
|
816
|
+
/**
|
|
817
|
+
* The names of what is on a map as TypeScript, for a game's code to use: `world.named<RoadNames>('gaet')` does not
|
|
818
|
+
* compile. The editor writes it next to the map (`road.names.ts` for `road.vgmap.json`), anew with every save
|
|
819
|
+
*
|
|
820
|
+
* @beta
|
|
821
|
+
*/
|
|
822
|
+
function mapNamesSource(stem, map) {
|
|
823
|
+
const type = typeName(stem);
|
|
824
|
+
const objects = map.objects.flatMap((object) => object.name ? [object.name] : []);
|
|
825
|
+
const camps = map.camps.flatMap((camp) => camp.name ? [camp.name] : []);
|
|
826
|
+
const regions = map.regions.map((region) => region.name);
|
|
827
|
+
const points = map.points.map((point) => point.name);
|
|
828
|
+
const paths = map.paths.map((path) => path.name);
|
|
829
|
+
const lights = namesIn(map.environment, "lights");
|
|
830
|
+
const effects = namesIn(map.environment, "effects");
|
|
831
|
+
return [
|
|
832
|
+
`// Written by the map editor with ${stem}.vgmap.json, anew with every save: not to be changed by hand`,
|
|
833
|
+
"",
|
|
834
|
+
`/** What is named on the map, for \`world.named<${type}Names>(...)\` */`,
|
|
835
|
+
`export type ${type}Names =${union(objects)}`,
|
|
836
|
+
"",
|
|
837
|
+
`/** The camps that are named on the map, for \`campNamed(world, ...)\` */`,
|
|
838
|
+
`export type ${type}Camps =${union(camps)}`,
|
|
839
|
+
"",
|
|
840
|
+
`/** The regions of the map, for \`world.areas\` and \`enteredArea\` */`,
|
|
841
|
+
`export type ${type}Regions =${union(regions)}`,
|
|
842
|
+
"",
|
|
843
|
+
`/** The points of the map, for \`world.map.point(...)\` */`,
|
|
844
|
+
`export type ${type}Points =${union(points)}`,
|
|
845
|
+
"",
|
|
846
|
+
`/** The paths of the map, for \`world.map.path(...)\` */`,
|
|
847
|
+
`export type ${type}Paths =${union(paths)}`,
|
|
848
|
+
"",
|
|
849
|
+
`/** The lights of the map that are named, for \`client.lights.named(...)\` */`,
|
|
850
|
+
`export type ${type}Lights =${union(lights)}`,
|
|
851
|
+
"",
|
|
852
|
+
`/** The effects of the map that are named, for \`client.mapEffects.named(...)\` */`,
|
|
853
|
+
`export type ${type}Effects =${union(effects)}`,
|
|
854
|
+
"",
|
|
855
|
+
`/** The ids of the regions, points and paths by name: what a field that names one keeps (it is handed the name) */`,
|
|
856
|
+
`export const ${type}Ids = ${idsOf(map)} as const`,
|
|
857
|
+
""
|
|
858
|
+
].join("\n");
|
|
859
|
+
}
|
|
860
|
+
/** The names of the things of a list of a map's environment that have one (`lights`, `effects`), as plain data */
|
|
861
|
+
function namesIn(environment, list) {
|
|
862
|
+
const things = environment?.[list];
|
|
863
|
+
if (!Array.isArray(things)) return [];
|
|
864
|
+
return things.flatMap((thing) => typeof thing?.name === "string" && thing.name ? [thing.name] : []);
|
|
865
|
+
}
|
|
866
|
+
/** The ids of a map's regions, points and paths by name, as the text of an object */
|
|
867
|
+
function idsOf(map) {
|
|
868
|
+
const part = (things) => `{ ${things.map((thing) => `${JSON.stringify(thing.name)}: ${thing.id}`).join(", ")} }`;
|
|
869
|
+
return `{ regions: ${part(map.regions)}, points: ${part(map.points)}, paths: ${part(map.paths)} }`;
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/mapMerge.ts
|
|
873
|
+
/** The parts of a map file that are lists of things with ids, merged thing by thing: the map's table says (`MAP_FIELDS`) */
|
|
874
|
+
var BY_ID = new Set(BY_ID$1);
|
|
875
|
+
/** What they are as text, to be compared: the same value, the same text */
|
|
876
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
877
|
+
/**
|
|
878
|
+
* The three-way choice of one value: what only one side changed is that side's; both alike, either; both otherwise,
|
|
879
|
+
* ours, and a conflict. `undefined` is a value too: a part one side took out
|
|
880
|
+
*/
|
|
881
|
+
function pick(base, ours, theirs) {
|
|
882
|
+
if (same(ours, theirs)) return {
|
|
883
|
+
value: ours,
|
|
884
|
+
conflict: false
|
|
885
|
+
};
|
|
886
|
+
if (same(ours, base)) return {
|
|
887
|
+
value: theirs,
|
|
888
|
+
conflict: false
|
|
889
|
+
};
|
|
890
|
+
if (same(theirs, base)) return {
|
|
891
|
+
value: ours,
|
|
892
|
+
conflict: false
|
|
893
|
+
};
|
|
894
|
+
return {
|
|
895
|
+
value: ours,
|
|
896
|
+
conflict: true
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/** What a thing of a list is called in what is said: its kind and its id, and its name or type where it has one */
|
|
900
|
+
function nameOf(list, entry, id) {
|
|
901
|
+
return `${list === "objects" ? "object" : list.replace(/s$/, "")} ${id}${typeof entry?.name === "string" ? ` "${entry.name}"` : typeof entry?.type === "string" ? ` (${entry.type})` : ""}`;
|
|
902
|
+
}
|
|
903
|
+
/** Merges two lists of things by their ids: each thing chosen as a value of its own, in the order of their ids */
|
|
904
|
+
function mergeList(list, base, ours, theirs, conflicts) {
|
|
905
|
+
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
|
|
906
|
+
const [b, o, t] = [
|
|
907
|
+
byId(base),
|
|
908
|
+
byId(ours),
|
|
909
|
+
byId(theirs)
|
|
910
|
+
];
|
|
911
|
+
const ids = [.../* @__PURE__ */ new Set([
|
|
912
|
+
...b.keys(),
|
|
913
|
+
...o.keys(),
|
|
914
|
+
...t.keys()
|
|
915
|
+
])].sort((x, y) => x - y);
|
|
916
|
+
const merged = [];
|
|
917
|
+
for (const id of ids) {
|
|
918
|
+
const chosen = pick(b.get(id), o.get(id), t.get(id));
|
|
919
|
+
if (chosen.conflict) {
|
|
920
|
+
const what = nameOf(list, o.get(id) ?? t.get(id), id);
|
|
921
|
+
conflicts.push(o.has(id) && t.has(id) ? `${what} was changed on both sides: ours is kept` : `${what} was changed on one side and taken out on the other: ours is kept`);
|
|
922
|
+
}
|
|
923
|
+
if (chosen.value !== void 0) merged.push(chosen.value);
|
|
924
|
+
}
|
|
925
|
+
return merged;
|
|
926
|
+
}
|
|
927
|
+
/** Whether a value is a list of things with ids: what of a map's look is merged thing by thing (its lights, effects, decals) */
|
|
928
|
+
var listOfIds = (value) => Array.isArray(value) && value.every((entry) => !!entry && typeof entry === "object" && typeof entry.id === "number");
|
|
929
|
+
/**
|
|
930
|
+
* Merges what a map looks like (`GameMap.environment`, the client's to read) part by part: a list of things with ids
|
|
931
|
+
* (its lights, effects, decals, sounds) thing by thing, as the map's own lists are, and each other part as a value
|
|
932
|
+
*/
|
|
933
|
+
function mergeEnvironment(base, ours, theirs, conflicts) {
|
|
934
|
+
const plain = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
935
|
+
if (![
|
|
936
|
+
base,
|
|
937
|
+
ours,
|
|
938
|
+
theirs
|
|
939
|
+
].every((value) => value === void 0 || plain(value))) return pick(base, ours, theirs);
|
|
940
|
+
const [b, o, t] = [
|
|
941
|
+
base,
|
|
942
|
+
ours,
|
|
943
|
+
theirs
|
|
944
|
+
].map((value) => value ?? {});
|
|
945
|
+
const merged = {};
|
|
946
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
947
|
+
...Object.keys(b),
|
|
948
|
+
...Object.keys(o),
|
|
949
|
+
...Object.keys(t)
|
|
950
|
+
])) {
|
|
951
|
+
if ([
|
|
952
|
+
b[key],
|
|
953
|
+
o[key],
|
|
954
|
+
t[key]
|
|
955
|
+
].every((value) => value === void 0 || listOfIds(value))) {
|
|
956
|
+
const list = mergeList(key, b[key] ?? [], o[key] ?? [], t[key] ?? [], conflicts);
|
|
957
|
+
if (list.length || o[key] !== void 0) merged[key] = list;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const chosen = pick(b[key], o[key], t[key]);
|
|
961
|
+
if (chosen.conflict) conflicts.push(`the map's look's ${key} was changed on both sides: ours is kept`);
|
|
962
|
+
if (chosen.value !== void 0) merged[key] = chosen.value;
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
value: Object.keys(merged).length || ours !== void 0 ? merged : void 0,
|
|
966
|
+
conflict: false
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Merges two versions of a map from the one they came from: parts and things by their ids (those of its look too: its
|
|
971
|
+
* lights, effects, decals), the ground cell by cell. Never throws for what both changed: ours is kept there, and said
|
|
972
|
+
* (`conflicts`). Maps of different sizes are not merged (ours, and said)
|
|
973
|
+
*
|
|
974
|
+
* @beta
|
|
975
|
+
*/
|
|
976
|
+
function mergeMaps(base, ours, theirs) {
|
|
977
|
+
const conflicts = [];
|
|
978
|
+
if (ours.width !== theirs.width || ours.depth !== theirs.depth || base.width !== ours.width || base.depth !== ours.depth) return {
|
|
979
|
+
map: ours,
|
|
980
|
+
conflicts: ["the size of the map changed: ours is kept whole"]
|
|
981
|
+
};
|
|
982
|
+
const [b, o, t] = [
|
|
983
|
+
base,
|
|
984
|
+
ours,
|
|
985
|
+
theirs
|
|
986
|
+
].map((map) => serializeMap(map));
|
|
987
|
+
const merged = { ...o };
|
|
988
|
+
const keys = /* @__PURE__ */ new Set([
|
|
989
|
+
...Object.keys(b),
|
|
990
|
+
...Object.keys(o),
|
|
991
|
+
...Object.keys(t)
|
|
992
|
+
]);
|
|
993
|
+
for (const key of keys) {
|
|
994
|
+
if (key === "format" || LAYERS$1.includes(key)) continue;
|
|
995
|
+
if (BY_ID.has(key) && [
|
|
996
|
+
b[key] ?? [],
|
|
997
|
+
o[key] ?? [],
|
|
998
|
+
t[key] ?? []
|
|
999
|
+
].every(Array.isArray)) {
|
|
1000
|
+
const list = mergeList(key, b[key] ?? [], o[key] ?? [], t[key] ?? [], conflicts);
|
|
1001
|
+
if (list.length || o[key] !== void 0) merged[key] = list;
|
|
1002
|
+
else delete merged[key];
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const chosen = key === "environment" ? mergeEnvironment(b[key], o[key], t[key], conflicts) : pick(b[key], o[key], t[key]);
|
|
1006
|
+
if (chosen.conflict) conflicts.push(`the map's ${key} was changed on both sides: ours is kept`);
|
|
1007
|
+
if (chosen.value === void 0) delete merged[key];
|
|
1008
|
+
else merged[key] = chosen.value;
|
|
1009
|
+
}
|
|
1010
|
+
const map = parseMap(merged);
|
|
1011
|
+
const paintable = same(o.tileset, t.tileset) && same(b.tileset, o.tileset);
|
|
1012
|
+
for (const layer of LAYERS$1) {
|
|
1013
|
+
if (layer === "tiles" && !paintable) {
|
|
1014
|
+
if (!same(Array.from(ours.tiles), Array.from(theirs.tiles))) conflicts.push("the paint was changed on both sides with different tiles: ours is kept");
|
|
1015
|
+
map.tiles = ours.tiles.slice();
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const [from, mine, yours] = [
|
|
1019
|
+
base[layer],
|
|
1020
|
+
ours[layer],
|
|
1021
|
+
theirs[layer]
|
|
1022
|
+
];
|
|
1023
|
+
const into = mine.slice();
|
|
1024
|
+
let both = 0;
|
|
1025
|
+
for (let i = 0; i < into.length; i++) {
|
|
1026
|
+
if (mine[i] === yours[i]) continue;
|
|
1027
|
+
if (mine[i] === from[i]) into[i] = yours[i];
|
|
1028
|
+
else if (yours[i] !== from[i]) both++;
|
|
1029
|
+
}
|
|
1030
|
+
if (both) conflicts.push(`${both} ${layer === "ground" ? "corners of the ground" : `cells of the ${layer === "tiles" ? "paint" : layer}`} were changed on both sides: ours is kept there`);
|
|
1031
|
+
map[layer] = into;
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
map,
|
|
1035
|
+
conflicts
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/presetGround.ts
|
|
1040
|
+
/**
|
|
1041
|
+
* Ready-made grounds a game takes or leaves, beside its own: the tiles of an RTS's ground (grass, dark grass, dirt,
|
|
1042
|
+
* road, sand, rock, snow), which the client draws with its `TILE_LOOKS`, and the blight a race spoils ground with,
|
|
1043
|
+
* which the client draws with its `blightLook`. They say only what there is; what they do is the game's
|
|
1044
|
+
*/
|
|
1045
|
+
/**
|
|
1046
|
+
* The tiles of an RTS's ground, by the ids the client's `TILE_LOOKS` has looks for: `tiles: presetTiles` in the rules,
|
|
1047
|
+
* `tiles: TILE_LOOKS` in the view. A game adds its own beside them, or takes some (`presetTiles.filter(...)`)
|
|
1048
|
+
*
|
|
1049
|
+
* @beta
|
|
1050
|
+
*/
|
|
1051
|
+
var presetTiles = [
|
|
1052
|
+
defineTile({
|
|
1053
|
+
id: "grass",
|
|
1054
|
+
name: "Grass"
|
|
1055
|
+
}),
|
|
1056
|
+
defineTile({
|
|
1057
|
+
id: "darkGrass",
|
|
1058
|
+
name: "Dark grass"
|
|
1059
|
+
}),
|
|
1060
|
+
defineTile({
|
|
1061
|
+
id: "dirt",
|
|
1062
|
+
name: "Dirt"
|
|
1063
|
+
}),
|
|
1064
|
+
defineTile({
|
|
1065
|
+
id: "road",
|
|
1066
|
+
name: "Road"
|
|
1067
|
+
}),
|
|
1068
|
+
defineTile({
|
|
1069
|
+
id: "sand",
|
|
1070
|
+
name: "Sand"
|
|
1071
|
+
}),
|
|
1072
|
+
defineTile({
|
|
1073
|
+
id: "rock",
|
|
1074
|
+
name: "Rock"
|
|
1075
|
+
}),
|
|
1076
|
+
defineTile({
|
|
1077
|
+
id: "snow",
|
|
1078
|
+
name: "Snow"
|
|
1079
|
+
})
|
|
1080
|
+
];
|
|
1081
|
+
/**
|
|
1082
|
+
* Blight: ground spoiled, a layer of cells a map's maker paints and the rules spread (`world.setCells('blight', …)`),
|
|
1083
|
+
* drawn by the client's `blightLook`. What it does (who builds on it, who heals on it) is the game's: melee's
|
|
1084
|
+
* undead in the example
|
|
1085
|
+
*
|
|
1086
|
+
* @beta
|
|
1087
|
+
*/
|
|
1088
|
+
var presetBlight = defineCellLayer({
|
|
1089
|
+
id: "blight",
|
|
1090
|
+
name: "Blight",
|
|
1091
|
+
description: "Spoiled ground: what the game says of it, a race that builds on it, one that heals on it",
|
|
1092
|
+
color: [
|
|
1093
|
+
.45,
|
|
1094
|
+
.3,
|
|
1095
|
+
.55
|
|
1096
|
+
]
|
|
1097
|
+
});
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/presetBuildings.ts
|
|
1100
|
+
/**
|
|
1101
|
+
* A fountain: a building that keeps the game's aura on who is around it (`aura`, the id of a passive ability with an
|
|
1102
|
+
* `aura` effect: a fountain of health's regeneration, a fountain of mana's). It cannot be hurt; a map puts it down
|
|
1103
|
+
* for the neutral player, so the aura says `affects: 'all'` to reach everyone (the neutral player is nobody's ally).
|
|
1104
|
+
* What is given over the fountain's own goes over it: `presetFountain('healingWaters', { id: 'fountainOfHealth' })`
|
|
1105
|
+
*
|
|
1106
|
+
* @beta
|
|
1107
|
+
*/
|
|
1108
|
+
function presetFountain(aura, over = {}) {
|
|
1109
|
+
return {
|
|
1110
|
+
id: "fountain",
|
|
1111
|
+
name: "Fountain",
|
|
1112
|
+
kind: "building",
|
|
1113
|
+
radius: 1.5,
|
|
1114
|
+
footprint: {
|
|
1115
|
+
w: 3,
|
|
1116
|
+
d: 3
|
|
1117
|
+
},
|
|
1118
|
+
stats: { health: 1e3 },
|
|
1119
|
+
sight: 6,
|
|
1120
|
+
invulnerable: true,
|
|
1121
|
+
abilities: [aura],
|
|
1122
|
+
...over
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* A marketplace's abilities: one for each trade (`MarketRate`), cast at once (`target: 'none'`), with an `exchange`
|
|
1127
|
+
* effect that trades the caster's owner's resources at its rate. A building of the game's lists their ids in its
|
|
1128
|
+
* `abilities` (`abilities: market.map((ability) => ability.id)`), the abilities go in the rules' `abilities`. With
|
|
1129
|
+
* less than the amount to sell, the player is told and nothing goes. The computer player does not cast them in a
|
|
1130
|
+
* fight (their `ai` says not now); its brain's `spells` may
|
|
1131
|
+
*
|
|
1132
|
+
* @beta
|
|
1133
|
+
*/
|
|
1134
|
+
function presetMarket(options) {
|
|
1135
|
+
return options.rates.map(({ sell, buy, rate, amount = 100, id, name, hotkey }) => ({
|
|
1136
|
+
id: id ?? `sell-${sell}-for-${buy}`,
|
|
1137
|
+
name: name ?? `Sell ${sell} for ${buy}`,
|
|
1138
|
+
description: `Trades ${amount} ${sell} for ${Math.floor(amount * rate + 1e-9)} ${buy}`,
|
|
1139
|
+
target: "none",
|
|
1140
|
+
maxLevel: 1,
|
|
1141
|
+
...hotkey !== void 0 && { hotkey },
|
|
1142
|
+
...options.cooldown !== void 0 && { cooldown: options.cooldown },
|
|
1143
|
+
effects: [{
|
|
1144
|
+
type: "exchange",
|
|
1145
|
+
sell,
|
|
1146
|
+
buy,
|
|
1147
|
+
rate,
|
|
1148
|
+
amount
|
|
1149
|
+
}],
|
|
1150
|
+
ai: () => null
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/packs/heroes/respawn.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* A game whose heroes come back by themselves after a while, at their side's home (see `RespawnOptions`): a saved timer
|
|
1157
|
+
* each, shown on the HUD as a revive is, and waited for by the computer player in place of reviving at an altar.
|
|
1158
|
+
* Revived at an altar in the meantime, it is back then, and the timer comes to nothing. For a game with the heroes pack
|
|
1159
|
+
* on (`withHeroes()`): one without it is refused
|
|
1160
|
+
*
|
|
1161
|
+
* @beta
|
|
1162
|
+
*/
|
|
1163
|
+
function withRespawn(options = {}) {
|
|
1164
|
+
const time = options.time ?? ((_world, hero) => 4 + 2 * (heroOf(hero)?.level ?? 1));
|
|
1165
|
+
const comeBack = defineTimer("respawn", (world, data) => {
|
|
1166
|
+
const hero = world.get(data.hero) ?? [...world.index.with(heroState, data.player)].find((each) => each.id === data.hero);
|
|
1167
|
+
const waiting = hero?.get(respawning);
|
|
1168
|
+
if (!hero || !waiting || data.since !== void 0 && waiting.since !== data.since) return;
|
|
1169
|
+
hero.remove(respawning);
|
|
1170
|
+
if (hero.alive || !hero.has(heroState)) return;
|
|
1171
|
+
const at = options.at?.(world, hero) ?? homeOf(world, hero.owner, hero.pos) ?? hero.pos;
|
|
1172
|
+
reviveHero(world, hero, at);
|
|
1173
|
+
});
|
|
1174
|
+
const fell = defineListener({
|
|
1175
|
+
id: "respawn.fell",
|
|
1176
|
+
event: entityDied,
|
|
1177
|
+
run(world, { entity }) {
|
|
1178
|
+
if (!entity.has(heroState) || options.heroes && !options.heroes(world, entity)) return;
|
|
1179
|
+
const wait = Math.max(0, time(world, entity));
|
|
1180
|
+
entity.set(respawning, {
|
|
1181
|
+
since: world.time,
|
|
1182
|
+
wait
|
|
1183
|
+
});
|
|
1184
|
+
world.timers.after(wait, comeBack, {
|
|
1185
|
+
hero: entity.id,
|
|
1186
|
+
player: entity.owner,
|
|
1187
|
+
since: world.time
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
return {
|
|
1192
|
+
id: "respawn",
|
|
1193
|
+
options,
|
|
1194
|
+
requires: [HEROES],
|
|
1195
|
+
components: [respawning],
|
|
1196
|
+
timers: [comeBack],
|
|
1197
|
+
listeners: [fell]
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Recall (features-plan F2): a hero stands and channels for a few seconds, then is at its side's home (`homeOf`),
|
|
1202
|
+
* however far; hurt in the meantime, or told to do something else, and it stays where it is. An ability to give heroes
|
|
1203
|
+
* (`UnitTypeDef.abilities`), made of the engine's parts: a channel (`AbilityDef.channel`) that `breaksOnDamage`, and the
|
|
1204
|
+
* `teleport` effect when it is `finished`. Its id, name, how long it takes, its cooldown and key are the game's to say
|
|
1205
|
+
*
|
|
1206
|
+
* @beta
|
|
1207
|
+
*/
|
|
1208
|
+
function recallAbility(options = {}) {
|
|
1209
|
+
const duration = options.duration ?? 6;
|
|
1210
|
+
return {
|
|
1211
|
+
id: options.id ?? "recall",
|
|
1212
|
+
name: options.name ?? "Recall",
|
|
1213
|
+
description: `After ${duration} seconds standing still, unhurt, the hero is back home.`,
|
|
1214
|
+
hotkey: options.hotkey ?? "B",
|
|
1215
|
+
target: "none",
|
|
1216
|
+
cooldown: options.cooldown ?? 20,
|
|
1217
|
+
maxLevel: 1,
|
|
1218
|
+
effects: [],
|
|
1219
|
+
channel: {
|
|
1220
|
+
duration,
|
|
1221
|
+
breaksOnDamage: true,
|
|
1222
|
+
finished: [{
|
|
1223
|
+
type: "teleport",
|
|
1224
|
+
to: "home"
|
|
1225
|
+
}]
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/friendlyFire.ts
|
|
1231
|
+
/**
|
|
1232
|
+
* The friendly fire pack's id
|
|
1233
|
+
*
|
|
1234
|
+
* @beta
|
|
1235
|
+
*/
|
|
1236
|
+
var FRIENDLY_FIRE = "core.friendlyFire";
|
|
1237
|
+
/**
|
|
1238
|
+
* Friendly fire, as in classic RTS games: a unit told to attack (the attack command, never a right click) one of its own or
|
|
1239
|
+
* an ally's attacks it. Never of its own accord: units do not pick their own side to attack, nor fight back at it.
|
|
1240
|
+
* Nothing of a neutral player's (a rock, a critter) is made a target by it
|
|
1241
|
+
*
|
|
1242
|
+
* @beta
|
|
1243
|
+
*/
|
|
1244
|
+
function withFriendlyFire(options = {}) {
|
|
1245
|
+
const attacks = options.attacks ?? (() => true);
|
|
1246
|
+
return {
|
|
1247
|
+
id: FRIENDLY_FIRE,
|
|
1248
|
+
options,
|
|
1249
|
+
hooks: [defineHook({
|
|
1250
|
+
hook: "canAttack",
|
|
1251
|
+
id: "friendlyFire",
|
|
1252
|
+
run(world, event) {
|
|
1253
|
+
const { attacker, target } = event;
|
|
1254
|
+
if (event.allowed || !event.ordered || world.player(target.owner).neutral) return;
|
|
1255
|
+
if (world.areEnemies(attacker.owner, target.owner)) return;
|
|
1256
|
+
if (attackReaches(world, attacker, target) && attacks(world, attacker, target)) event.allowed = true;
|
|
1257
|
+
}
|
|
1258
|
+
})]
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/ids.ts
|
|
1263
|
+
/**
|
|
1264
|
+
* Ids the compiler knows. A game's ids are text to the engine (`world.spawn('skeleton', ...)`), and a slip of the
|
|
1265
|
+
* finger in one is found when the game runs, if it is found. Named once with `defineIds`, they are checked as they are
|
|
1266
|
+
* written, completed by the editor, and renamed in one place:
|
|
1267
|
+
*
|
|
1268
|
+
* export const UNIT = defineIds('skeleton', 'runner', 'brute')
|
|
1269
|
+
* export type TdUnit = IdOf<typeof UNIT> // 'skeleton' | 'runner' | 'brute'
|
|
1270
|
+
*
|
|
1271
|
+
* { id: UNIT.skeleton, ... } // in the unit's definition
|
|
1272
|
+
* world.spawn(UNIT.skeletn, ...) // does not compile
|
|
1273
|
+
* const view: GameView<TdUnit> = { units: { looks: { skeletn: ... } } } // nor does this
|
|
1274
|
+
*
|
|
1275
|
+
* It is a help, not a rule: plain text works wherever an id goes, and what the compiler cannot see is checked when the
|
|
1276
|
+
* game is put together (`defineGame`)
|
|
1277
|
+
*
|
|
1278
|
+
* @beta
|
|
1279
|
+
*/
|
|
1280
|
+
function defineIds(...names) {
|
|
1281
|
+
const out = {};
|
|
1282
|
+
for (const name of names) {
|
|
1283
|
+
if (name in out) throw new Error(`defineIds: "${name}" is there twice`);
|
|
1284
|
+
out[name] = name;
|
|
1285
|
+
}
|
|
1286
|
+
return Object.freeze(out);
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/gameFiles.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* A file of the game's own data, for `GameModule.files`: `defineGameFile('waves.json', wavesFile, readWaves)`, read by
|
|
1292
|
+
* the rules with `world.registry.file(waves)`
|
|
1293
|
+
*
|
|
1294
|
+
* @beta
|
|
1295
|
+
*/
|
|
1296
|
+
function defineGameFile(id, data, read) {
|
|
1297
|
+
return {
|
|
1298
|
+
id,
|
|
1299
|
+
data,
|
|
1300
|
+
read
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/packs/pack.ts
|
|
1305
|
+
/**
|
|
1306
|
+
* Whether a game has a pack on, by its id: `hasPack(game, HEROES)`, what the editor asks before it shows a hero's
|
|
1307
|
+
* fields
|
|
1308
|
+
*
|
|
1309
|
+
* @beta
|
|
1310
|
+
*/
|
|
1311
|
+
function hasPack(game, id) {
|
|
1312
|
+
return !!game?.packs?.some((pack) => pack.id === id);
|
|
1313
|
+
}
|
|
1314
|
+
//#endregion
|
|
1315
|
+
//#region src/packs/director/index.ts
|
|
1316
|
+
/** @beta */
|
|
1317
|
+
var DIRECTOR = "director";
|
|
1318
|
+
/**
|
|
1319
|
+
* The cinematic playing on each player's screen, by its id, as their screen said (`cinematicPlaying`): whose
|
|
1320
|
+
* director's commands are taken
|
|
1321
|
+
*
|
|
1322
|
+
* @beta
|
|
1323
|
+
*/
|
|
1324
|
+
var directing = defineComponent("director.playing", { default: () => ({}) });
|
|
1325
|
+
/**
|
|
1326
|
+
* The units a cinematic made invulnerable (`director` with `invulnerable`): nothing hurts or kills them until it says
|
|
1327
|
+
* otherwise
|
|
1328
|
+
*
|
|
1329
|
+
* @beta
|
|
1330
|
+
*/
|
|
1331
|
+
var directorInvulnerable = defineComponent("director.invulnerable");
|
|
1332
|
+
/**
|
|
1333
|
+
* What a director's command does (`DirectorCommand.action`)
|
|
1334
|
+
*
|
|
1335
|
+
* @beta
|
|
1336
|
+
*/
|
|
1337
|
+
var DIRECTOR_ACTIONS = [
|
|
1338
|
+
"spawn",
|
|
1339
|
+
"remove",
|
|
1340
|
+
"kill",
|
|
1341
|
+
"teleport",
|
|
1342
|
+
"scale",
|
|
1343
|
+
"invulnerable",
|
|
1344
|
+
"owner",
|
|
1345
|
+
"order",
|
|
1346
|
+
"facing"
|
|
1347
|
+
];
|
|
1348
|
+
/**
|
|
1349
|
+
* The orders a director's command gives (`order`): to a spot, or at a unit
|
|
1350
|
+
*
|
|
1351
|
+
* @beta
|
|
1352
|
+
*/
|
|
1353
|
+
var DIRECTOR_ORDERS = [
|
|
1354
|
+
"move",
|
|
1355
|
+
"attack",
|
|
1356
|
+
"attackMove",
|
|
1357
|
+
"patrol",
|
|
1358
|
+
"stop",
|
|
1359
|
+
"hold"
|
|
1360
|
+
];
|
|
1361
|
+
/**
|
|
1362
|
+
* A player's screen says a cinematic began on it (`playing: true`, its id) or ended: while one plays there, the
|
|
1363
|
+
* director's commands it sends are taken
|
|
1364
|
+
*
|
|
1365
|
+
* @beta
|
|
1366
|
+
*/
|
|
1367
|
+
var cinematicPlayingCommand = defineCommand("cinematicPlaying", {
|
|
1368
|
+
cinematic: string,
|
|
1369
|
+
playing: bool
|
|
1370
|
+
}, (world, player, command) => {
|
|
1371
|
+
const known = directorOptions(world).cinematics;
|
|
1372
|
+
if (command.playing && known && !known.includes(command.cinematic)) return refuse(world, player, `"${command.cinematic}" is no cinematic the director plays in (withDirector's cinematics)`);
|
|
1373
|
+
const now = { ...world.state(directing) };
|
|
1374
|
+
if (command.playing) now[player] = command.cinematic;
|
|
1375
|
+
else if (now[player] === command.cinematic) delete now[player];
|
|
1376
|
+
world.setState(directing, now);
|
|
1377
|
+
});
|
|
1378
|
+
/** The options the game's director pack was made with */
|
|
1379
|
+
var directorOptions = (world) => world.registry.module?.packs?.find((pack) => pack.id === "director")?.options ?? {};
|
|
1380
|
+
/** Turned away, and counted as what the player's own client never sends */
|
|
1381
|
+
var refuse = (world, player, reason) => world.events.emit(commandRejected, {
|
|
1382
|
+
player,
|
|
1383
|
+
type: "director",
|
|
1384
|
+
kind: "notDirecting",
|
|
1385
|
+
reason
|
|
1386
|
+
});
|
|
1387
|
+
/**
|
|
1388
|
+
* A cinematic's say over the game (see the top of this file): `action` with what it takes. `unit` is the one it is
|
|
1389
|
+
* done to (any player's); `spawn` puts one of `unitType` down for `owner` at `at`, turned to `facing`, named `name` for
|
|
1390
|
+
* what comes after it in the cinematic (`world.named`)
|
|
1391
|
+
*
|
|
1392
|
+
* @beta
|
|
1393
|
+
*/
|
|
1394
|
+
var directorCommand = defineCommand("director", {
|
|
1395
|
+
cinematic: string,
|
|
1396
|
+
action: oneOf(...DIRECTOR_ACTIONS),
|
|
1397
|
+
unit: optional(id),
|
|
1398
|
+
unitType: optional(string),
|
|
1399
|
+
owner: optional(id),
|
|
1400
|
+
at: optional(point),
|
|
1401
|
+
facing: optional(number),
|
|
1402
|
+
scale: optional(number),
|
|
1403
|
+
on: optional(bool),
|
|
1404
|
+
name: optional(string),
|
|
1405
|
+
order: optional(oneOf(...DIRECTOR_ORDERS)),
|
|
1406
|
+
target: optional(id)
|
|
1407
|
+
}, (world, player, command) => {
|
|
1408
|
+
const playing = world.state(directing)[player];
|
|
1409
|
+
if (playing !== command.cinematic) {
|
|
1410
|
+
refuse(world, player, playing ? `the cinematic playing is "${playing}", not "${command.cinematic}"` : "no cinematic plays on their screen");
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const { action } = command;
|
|
1414
|
+
if (action === "spawn") {
|
|
1415
|
+
if (!command.unitType || !world.registry.units.has(command.unitType)) return refuse(world, player, `there is no unit type "${command.unitType}"`);
|
|
1416
|
+
const owner = command.owner ?? player;
|
|
1417
|
+
if (!world.players.has(owner)) return refuse(world, player, `there is no player ${owner}`);
|
|
1418
|
+
if (!command.at) return refuse(world, player, "a unit put down needs where (at)");
|
|
1419
|
+
world.spawn(command.unitType, owner, command.at, {
|
|
1420
|
+
...command.facing !== void 0 && { facing: command.facing },
|
|
1421
|
+
...command.name && { name: command.name }
|
|
1422
|
+
});
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const entity = command.unit === void 0 ? void 0 : world.get(command.unit);
|
|
1426
|
+
if (!entity?.alive) return refuse(world, player, `there is no unit ${command.unit}`);
|
|
1427
|
+
act(world, entity, command);
|
|
1428
|
+
});
|
|
1429
|
+
/** What a director's command does to a unit there is */
|
|
1430
|
+
function act(world, entity, command) {
|
|
1431
|
+
switch (command.action) {
|
|
1432
|
+
case "remove":
|
|
1433
|
+
world.kill(entity, void 0, false, "removed");
|
|
1434
|
+
break;
|
|
1435
|
+
case "kill":
|
|
1436
|
+
world.kill(entity);
|
|
1437
|
+
break;
|
|
1438
|
+
case "teleport":
|
|
1439
|
+
if (command.at) world.place(entity, command.at);
|
|
1440
|
+
break;
|
|
1441
|
+
case "scale":
|
|
1442
|
+
if (command.scale !== void 0 && command.scale > 0) entity.scale = command.scale;
|
|
1443
|
+
break;
|
|
1444
|
+
case "invulnerable":
|
|
1445
|
+
if (command.on === false) entity.remove(directorInvulnerable);
|
|
1446
|
+
else entity.set(directorInvulnerable, true);
|
|
1447
|
+
break;
|
|
1448
|
+
case "owner":
|
|
1449
|
+
if (command.owner !== void 0 && world.players.has(command.owner)) world.setOwner(entity, command.owner);
|
|
1450
|
+
break;
|
|
1451
|
+
case "facing":
|
|
1452
|
+
if (command.facing !== void 0) entity.facing = command.facing;
|
|
1453
|
+
break;
|
|
1454
|
+
case "order": {
|
|
1455
|
+
const kind = command.order ?? "move";
|
|
1456
|
+
const target = command.target === void 0 ? void 0 : world.get(command.target);
|
|
1457
|
+
const init = kind === "stop" || kind === "hold" ? { type: kind } : kind === "attack" && target ? {
|
|
1458
|
+
type: "attack",
|
|
1459
|
+
target: target.id
|
|
1460
|
+
} : command.at ? {
|
|
1461
|
+
type: kind === "attack" ? "attackMove" : kind,
|
|
1462
|
+
point: command.at
|
|
1463
|
+
} : void 0;
|
|
1464
|
+
if (init) giveOrder(world, entity, createOrder(world, init));
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/** Nothing hurts or kills a unit a cinematic made invulnerable */
|
|
1470
|
+
var invulnerableHooks = [defineHook({
|
|
1471
|
+
hook: "beforeDamage",
|
|
1472
|
+
id: "director",
|
|
1473
|
+
run(_world, damage) {
|
|
1474
|
+
if (damage.target.has(directorInvulnerable)) damage.cancelled = true;
|
|
1475
|
+
}
|
|
1476
|
+
}), defineHook({
|
|
1477
|
+
hook: "beforeDeath",
|
|
1478
|
+
id: "director",
|
|
1479
|
+
run(_world, death) {
|
|
1480
|
+
if (death.entity.has(directorInvulnerable) && death.reason !== "removed") death.prevented = true;
|
|
1481
|
+
}
|
|
1482
|
+
})];
|
|
1483
|
+
/**
|
|
1484
|
+
* The director's pack (see the top of this file): its commands, what it keeps, and what keeps a unit it made
|
|
1485
|
+
* invulnerable so. `packs: [withDirector()]`, or `withDirector({ cinematics: ['intro'] })` for those alone
|
|
1486
|
+
*
|
|
1487
|
+
* @beta
|
|
1488
|
+
*/
|
|
1489
|
+
function withDirector(options = {}) {
|
|
1490
|
+
return {
|
|
1491
|
+
id: DIRECTOR,
|
|
1492
|
+
options,
|
|
1493
|
+
commands: [cinematicPlayingCommand, directorCommand],
|
|
1494
|
+
components: [directing, directorInvulnerable],
|
|
1495
|
+
hooks: invulnerableHooks
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region src/packs/moba/index.ts
|
|
1500
|
+
/**
|
|
1501
|
+
* The stats of a MOBA (`withMobaStats`): ability power, critical strikes, armor and magic penetration, life steal and
|
|
1502
|
+
* omnivamp, tenacity, slow resistance, heal and shield power, ability haste. The hero arena games' vocabulary and their
|
|
1503
|
+
* formulas, a pack a game opts into: one without it has none of these stats, and the engine's rules are the plainer
|
|
1504
|
+
* ones (no crits, mitigation by armor and magic resist alone, cooldowns as they are)
|
|
1505
|
+
*/
|
|
1506
|
+
/**
|
|
1507
|
+
* The MOBA stats pack's id
|
|
1508
|
+
*
|
|
1509
|
+
* @beta
|
|
1510
|
+
*/
|
|
1511
|
+
var MOBA_STATS = "core.mobaStats";
|
|
1512
|
+
/** The stats the pack brings, in the order a stat sheet shows them; `critDamage`'s default is the pack's option */
|
|
1513
|
+
var mobaStatDefs = (critDamage) => [
|
|
1514
|
+
{
|
|
1515
|
+
id: "abilityPower",
|
|
1516
|
+
name: "Ability power",
|
|
1517
|
+
description: "Makes abilities stronger, as far as they scale with it"
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
id: "critChance",
|
|
1521
|
+
name: "Critical chance",
|
|
1522
|
+
description: "Chance of a basic attack to strike critically, 0..1",
|
|
1523
|
+
format: "percent"
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
id: "critDamage",
|
|
1527
|
+
name: "Critical damage",
|
|
1528
|
+
description: "What a critical strike multiplies the damage with",
|
|
1529
|
+
default: critDamage,
|
|
1530
|
+
format: "percent"
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
id: "armorPenetration",
|
|
1534
|
+
name: "Armor penetration",
|
|
1535
|
+
description: "Part of the target's armor that is ignored, 0..1",
|
|
1536
|
+
format: "percent"
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
id: "lethality",
|
|
1540
|
+
name: "Lethality",
|
|
1541
|
+
description: "Armor of the target that is ignored, taken off after the part above"
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
id: "magicPenetrationPercent",
|
|
1545
|
+
name: "Magic penetration %",
|
|
1546
|
+
description: "Part of the target's magic resist that is ignored, 0..1",
|
|
1547
|
+
format: "percent"
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
id: "magicPenetration",
|
|
1551
|
+
name: "Magic penetration",
|
|
1552
|
+
description: "Magic resist of the target that is ignored, taken off after the part above"
|
|
1553
|
+
},
|
|
1554
|
+
{
|
|
1555
|
+
id: "lifeSteal",
|
|
1556
|
+
name: "Life steal",
|
|
1557
|
+
description: "Part of the damage of basic attacks that comes back as health",
|
|
1558
|
+
format: "percent"
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
id: "omnivamp",
|
|
1562
|
+
name: "Omnivamp",
|
|
1563
|
+
description: "Part of all damage dealt that comes back as health",
|
|
1564
|
+
format: "percent"
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
id: "tenacity",
|
|
1568
|
+
name: "Tenacity",
|
|
1569
|
+
description: "Part by which stuns, slows and the like are cut short, 0..1",
|
|
1570
|
+
format: "percent"
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
id: "slowResist",
|
|
1574
|
+
name: "Slow resist",
|
|
1575
|
+
description: "Part by which slows are weakened, 0..1",
|
|
1576
|
+
format: "percent"
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
id: "healShieldPower",
|
|
1580
|
+
name: "Heal and shield power",
|
|
1581
|
+
description: "Part by which the heals and shields this unit gives are stronger",
|
|
1582
|
+
format: "percent"
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
id: "abilityHaste",
|
|
1586
|
+
name: "Ability haste",
|
|
1587
|
+
description: "Shortens cooldowns: 100 of it halve them"
|
|
1588
|
+
}
|
|
1589
|
+
];
|
|
1590
|
+
/**
|
|
1591
|
+
* What is left of damage once the target's armor or magic resist, less what the source's penetration ignores, had its
|
|
1592
|
+
* say: the pack's `rules.combat.mitigation`
|
|
1593
|
+
*
|
|
1594
|
+
* @beta
|
|
1595
|
+
*/
|
|
1596
|
+
function penetratingMitigation(type, target, source) {
|
|
1597
|
+
if (type === "true") return 1;
|
|
1598
|
+
let resist = type === "physical" ? target.armor : target.magicResist;
|
|
1599
|
+
if (source && resist > 0) {
|
|
1600
|
+
const part = type === "physical" ? source.armorPenetration : source.magicPenetrationPercent;
|
|
1601
|
+
const flat = type === "physical" ? source.lethality : source.magicPenetration;
|
|
1602
|
+
resist = Math.max(resist * (1 - part) - flat, 0);
|
|
1603
|
+
}
|
|
1604
|
+
return resisted(resist);
|
|
1605
|
+
}
|
|
1606
|
+
/** Health back for damage dealt: omnivamp of everything, life steal of basic attacks. Before the engine's own handlers */
|
|
1607
|
+
var lifeSteal = defineHook({
|
|
1608
|
+
hook: "afterDamage",
|
|
1609
|
+
id: "mobaStats.lifeSteal",
|
|
1610
|
+
priority: -1,
|
|
1611
|
+
run(world, { target, source, taken, attack }) {
|
|
1612
|
+
if (!source?.alive || taken <= 0 || source === target) return;
|
|
1613
|
+
const stats = statsOf(world, source);
|
|
1614
|
+
const back = taken * (stats.omnivamp + (attack ? stats.lifeSteal : 0));
|
|
1615
|
+
if (back > 0) world.heal(source, back);
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
/** Stuns, slows and the like cut short by the target's tenacity: before what the game decides of a buff */
|
|
1619
|
+
var tenacity = defineHook({
|
|
1620
|
+
hook: "beforeBuff",
|
|
1621
|
+
id: "mobaStats.tenacity",
|
|
1622
|
+
priority: -1,
|
|
1623
|
+
run(world, event) {
|
|
1624
|
+
if (event.duration !== Infinity && isCrowdControl(event.buff)) event.duration *= 1 - statsOf(world, event.target).tenacity;
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
/**
|
|
1628
|
+
* The stats of a MOBA, for a game that has them: ability power (what `scaling: { abilityPower }` reads), critical
|
|
1629
|
+
* strikes, armor and magic penetration, life steal and omnivamp, tenacity, slow resistance, heal and shield power,
|
|
1630
|
+
* ability haste, with the hero arena games' formulas for each. A game without it has none of these stats
|
|
1631
|
+
*
|
|
1632
|
+
* @beta
|
|
1633
|
+
*/
|
|
1634
|
+
function withMobaStats(options = {}) {
|
|
1635
|
+
const { critDamage = 1.75, maxTenacity = .9 } = options;
|
|
1636
|
+
return {
|
|
1637
|
+
id: MOBA_STATS,
|
|
1638
|
+
options,
|
|
1639
|
+
stats: mobaStatDefs(critDamage),
|
|
1640
|
+
hooks: [lifeSteal, tenacity],
|
|
1641
|
+
rules: (rules) => {
|
|
1642
|
+
const { combat } = rules;
|
|
1643
|
+
const engine = defaultRules.combat;
|
|
1644
|
+
const own = (key, pack) => combat[key] === engine[key] ? pack : combat[key];
|
|
1645
|
+
const keep = combat.clampStats;
|
|
1646
|
+
return { combat: {
|
|
1647
|
+
critical: own("critical", (world, _attacker, stats) => stats.critChance > 0 && world.rng.next() < stats.critChance ? stats.critDamage : void 0),
|
|
1648
|
+
mitigation: own("mitigation", penetratingMitigation),
|
|
1649
|
+
clampStats(stats, all) {
|
|
1650
|
+
stats.critChance = clamp(stats.critChance, 0, 1);
|
|
1651
|
+
stats.tenacity = clamp(stats.tenacity, 0, maxTenacity);
|
|
1652
|
+
stats.slowResist = clamp(stats.slowResist, 0, 1);
|
|
1653
|
+
stats.armorPenetration = clamp(stats.armorPenetration, 0, 1);
|
|
1654
|
+
stats.magicPenetrationPercent = clamp(stats.magicPenetrationPercent, 0, 1);
|
|
1655
|
+
keep(stats, all);
|
|
1656
|
+
},
|
|
1657
|
+
cooldown: own("cooldown", (seconds, stats) => stats.abilityHaste > 0 ? seconds * 100 / (100 + stats.abilityHaste) : seconds),
|
|
1658
|
+
slowTaken: own("slowTaken", (stats, slow) => slow * (1 - stats.slowResist)),
|
|
1659
|
+
healPower: own("healPower", (world, source) => 1 + statsOf(world, source).healShieldPower)
|
|
1660
|
+
} };
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
//#endregion
|
|
1665
|
+
//#region src/techTree.ts
|
|
1666
|
+
/**
|
|
1667
|
+
* The tech tree of a game's rules: its things that are made or make something (scenery and the map's creeps left
|
|
1668
|
+
* out), the edges between them, and each one's tier. Tiers go by what makes what from what nothing makes and from the
|
|
1669
|
+
* buildings that make the builders, require nothing and are no other's upgrade (a hall; else, where everything is
|
|
1670
|
+
* made of something, the buildings that require nothing, are no upgrade and make something),
|
|
1671
|
+
* each thing after what it requires; a loop of requirements stops at the number of things
|
|
1672
|
+
*
|
|
1673
|
+
* @beta
|
|
1674
|
+
*/
|
|
1675
|
+
function techTree(registry) {
|
|
1676
|
+
const units = registry.units.all();
|
|
1677
|
+
const upgrades = registry.upgrades.all();
|
|
1678
|
+
const edges = [];
|
|
1679
|
+
const known = /* @__PURE__ */ new Set([...units.map((unit) => unit.id), ...upgrades.map((upgrade) => upgrade.id)]);
|
|
1680
|
+
const add = (from, to, kind) => {
|
|
1681
|
+
if (known.has(from) && known.has(to) && from !== to) edges.push({
|
|
1682
|
+
from,
|
|
1683
|
+
to,
|
|
1684
|
+
kind
|
|
1685
|
+
});
|
|
1686
|
+
};
|
|
1687
|
+
for (const unit of units) {
|
|
1688
|
+
for (const id of unit.trains ?? []) add(unit.id, id, "trains");
|
|
1689
|
+
for (const id of unit.builds ?? []) add(unit.id, id, "builds");
|
|
1690
|
+
for (const id of unit.hires ?? []) add(unit.id, id, "hires");
|
|
1691
|
+
for (const id of unit.researches ?? []) add(unit.id, id, "researches");
|
|
1692
|
+
for (const id of upgradesOf(unit)) add(unit.id, id, "upgradesTo");
|
|
1693
|
+
for (const id of unit.requires ?? []) add(id, unit.id, "requires");
|
|
1694
|
+
}
|
|
1695
|
+
for (const upgrade of upgrades) for (const id of upgrade.requires?.[0] ?? []) add(id, upgrade.id, "requires");
|
|
1696
|
+
const inTree = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
1697
|
+
const makes = edges.filter((edge) => edge.kind !== "requires");
|
|
1698
|
+
const made = new Set(makes.map((edge) => edge.to));
|
|
1699
|
+
const needs = /* @__PURE__ */ new Map();
|
|
1700
|
+
for (const edge of edges) if (edge.kind === "requires") needs.set(edge.to, [...needs.get(edge.to) ?? [], edge.from]);
|
|
1701
|
+
const buildings = new Set(units.filter((unit) => unit.kind !== "unit").map((unit) => unit.id));
|
|
1702
|
+
const upgraded = new Set(edges.filter((edge) => edge.kind === "upgradesTo").map((edge) => edge.to));
|
|
1703
|
+
let start = [...inTree].filter((id) => !made.has(id));
|
|
1704
|
+
const builders = new Set(units.filter((unit) => unit.builds?.length).map((unit) => unit.id));
|
|
1705
|
+
const first = [...inTree].filter((id) => buildings.has(id) && !needs.has(id) && !upgraded.has(id));
|
|
1706
|
+
const halls = first.filter((id) => makes.some((edge) => edge.from === id && builders.has(edge.to)));
|
|
1707
|
+
start = [.../* @__PURE__ */ new Set([...start, ...halls])];
|
|
1708
|
+
if (!start.length) start = first.filter((id) => makes.some((edge) => edge.from === id));
|
|
1709
|
+
if (!start.length) start = [...inTree].filter((id) => !needs.has(id));
|
|
1710
|
+
const tier = new Map(start.map((id) => [id, 0]));
|
|
1711
|
+
for (let queue = [...start]; queue.length;) {
|
|
1712
|
+
const next = [];
|
|
1713
|
+
for (const id of queue) for (const edge of makes) if (edge.from === id && !tier.has(edge.to)) {
|
|
1714
|
+
tier.set(edge.to, tier.get(id) + 1);
|
|
1715
|
+
next.push(edge.to);
|
|
1716
|
+
}
|
|
1717
|
+
queue = next;
|
|
1718
|
+
}
|
|
1719
|
+
for (let pass = 0; pass < inTree.size; pass++) {
|
|
1720
|
+
let moved = false;
|
|
1721
|
+
for (const [id, required] of needs) {
|
|
1722
|
+
const after = Math.max(...required.map((other) => (tier.get(other) ?? 0) + 1));
|
|
1723
|
+
if (after > (tier.get(id) ?? 0)) {
|
|
1724
|
+
tier.set(id, after);
|
|
1725
|
+
moved = true;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (!moved) break;
|
|
1729
|
+
}
|
|
1730
|
+
const nodes = [];
|
|
1731
|
+
for (const unit of units) if (inTree.has(unit.id)) nodes.push({
|
|
1732
|
+
id: unit.id,
|
|
1733
|
+
kind: unit.kind === "unit" ? "unit" : "building",
|
|
1734
|
+
name: unit.name,
|
|
1735
|
+
tier: tier.get(unit.id) ?? 0,
|
|
1736
|
+
...unit.race && { race: unit.race }
|
|
1737
|
+
});
|
|
1738
|
+
for (const upgrade of upgrades) if (inTree.has(upgrade.id)) nodes.push({
|
|
1739
|
+
id: upgrade.id,
|
|
1740
|
+
kind: "upgrade",
|
|
1741
|
+
name: upgrade.name,
|
|
1742
|
+
tier: tier.get(upgrade.id) ?? 0,
|
|
1743
|
+
...upgrade.race && { race: upgrade.race }
|
|
1744
|
+
});
|
|
1745
|
+
return {
|
|
1746
|
+
nodes,
|
|
1747
|
+
edges
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
//#endregion
|
|
1751
|
+
//#region src/hotReload.ts
|
|
1752
|
+
/**
|
|
1753
|
+
* A world took a new version of its game while it ran (`reloadGame`, hot reload): what the rules hear
|
|
1754
|
+
* (`world.events.on(gameReloaded, …)`), and every screen (`GameView.on`), to make again what it made of the old one
|
|
1755
|
+
*
|
|
1756
|
+
* @beta
|
|
1757
|
+
*/
|
|
1758
|
+
var gameReloaded = defineEvent("core.gameReloaded", { replicate: "everyone" });
|
|
1759
|
+
/**
|
|
1760
|
+
* Puts a new version of a world's game into it while it runs, as a game's files are saved during development (hot
|
|
1761
|
+
* reload): its unit types, abilities, buffs, items, upgrades and races, the code of their effects and of its orders,
|
|
1762
|
+
* and its rules. What `world.redefine` changed while it ran stays over them, and its units take their types as they
|
|
1763
|
+
* are now. What the world was made of otherwise (its systems, hooks, components, resources) is not: a change to those
|
|
1764
|
+
* wants a new game, which is said (`hotRules` loads the page again for one). Throws for another game, or a module that is no game (as `createRegistry`)
|
|
1765
|
+
*
|
|
1766
|
+
* @beta
|
|
1767
|
+
*/
|
|
1768
|
+
function reloadGame(world, game) {
|
|
1769
|
+
const { registry } = world;
|
|
1770
|
+
if (game.id !== registry.game) throw new Error(`The world plays "${registry.game}", not "${game.id}"`);
|
|
1771
|
+
registry.takeFrom(createRegistry(game));
|
|
1772
|
+
if (world.states.has(redefinitions)) restoreRedefinitions(registry, world.state(redefinitions));
|
|
1773
|
+
world.retype();
|
|
1774
|
+
world.score.listen();
|
|
1775
|
+
try {
|
|
1776
|
+
game.onReload?.(world);
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
world.log.error("game", `${game.id}'s onReload threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
1779
|
+
}
|
|
1780
|
+
world.log.info("game", `Took a new version of ${game.name ?? game.id}: its definitions and rules (its systems and hooks are the game's as it began)`);
|
|
1781
|
+
world.events.emit(gameReloaded, { game: game.id });
|
|
1782
|
+
}
|
|
1783
|
+
/** What of a game a running world keeps as it was made (`reloadGame`): a new version that changes any loads the page again */
|
|
1784
|
+
var NOT_RELOADED = [
|
|
1785
|
+
"systems",
|
|
1786
|
+
"withoutSystems",
|
|
1787
|
+
"hooks",
|
|
1788
|
+
"withoutHooks",
|
|
1789
|
+
"components",
|
|
1790
|
+
"resources"
|
|
1791
|
+
];
|
|
1792
|
+
/**
|
|
1793
|
+
* What a part of a game is, in words, to tell a new version's from the old: its code by its source, its data by its
|
|
1794
|
+
* values, so that a module evaluated again with the same code is the same
|
|
1795
|
+
*/
|
|
1796
|
+
function shapeOf(value, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
1797
|
+
if (typeof value === "function") return `f:${String(value)}`;
|
|
1798
|
+
if (value === null || typeof value !== "object") return value === void 0 ? "u" : JSON.stringify(value);
|
|
1799
|
+
if (seen.has(value) || depth > 8) return "…";
|
|
1800
|
+
seen.add(value);
|
|
1801
|
+
const shape = Array.isArray(value) ? `[${value.map((each) => shapeOf(each, depth + 1, seen)).join(",")}]` : `{${Object.keys(value).sort().map((key) => `${key}:${shapeOf(value[key], depth + 1, seen)}`).join(",")}}`;
|
|
1802
|
+
seen.delete(value);
|
|
1803
|
+
return shape;
|
|
1804
|
+
}
|
|
1805
|
+
/** Who hears of a new version of a game's rules or of a mod: the client, which tells the games and editors of the page */
|
|
1806
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
1807
|
+
var modListeners = /* @__PURE__ */ new Set();
|
|
1808
|
+
/**
|
|
1809
|
+
* Hears of every new version of a game's rules taken in the page (`hotRules`). Returns what stops it
|
|
1810
|
+
*
|
|
1811
|
+
* @beta
|
|
1812
|
+
*/
|
|
1813
|
+
function onNewGameVersion(listener) {
|
|
1814
|
+
listeners.add(listener);
|
|
1815
|
+
return () => listeners.delete(listener);
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Hears of every new version of a mod taken in the page (`hotMod`). Returns what stops it
|
|
1819
|
+
*
|
|
1820
|
+
* @beta
|
|
1821
|
+
*/
|
|
1822
|
+
function onNewModVersion(listener) {
|
|
1823
|
+
modListeners.add(listener);
|
|
1824
|
+
return () => modListeners.delete(listener);
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* A mod's module's new version, taken while it runs (hot reload), as `hotRules` takes a game's: at its end,
|
|
1828
|
+
*
|
|
1829
|
+
* if (import.meta.hot) import.meta.hot.accept((module) => hotMod(import.meta.hot, goldRush, module?.goldRush))
|
|
1830
|
+
*
|
|
1831
|
+
* The mod given is made the new one in place, and every game of the page played with it is made again of its parts as
|
|
1832
|
+
* they are now (`recompose`) and takes that. What does not go (no new module, another mod, mods that no longer go
|
|
1833
|
+
* together) loads the page again
|
|
1834
|
+
*
|
|
1835
|
+
* @beta
|
|
1836
|
+
*/
|
|
1837
|
+
function hotMod(hot, current, next) {
|
|
1838
|
+
const mod = next;
|
|
1839
|
+
if (!mod || typeof mod !== "object" || mod.id !== current.id) return hot?.invalidate(`${current.id}: its module is not there as it was`);
|
|
1840
|
+
try {
|
|
1841
|
+
replaceIn(current, mod);
|
|
1842
|
+
for (const listener of modListeners) listener(current);
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
rootLogger.warn("game", `The mod ${current.id}'s new version could not be taken while it runs: the page loads again`, { error: String(error) });
|
|
1845
|
+
hot?.invalidate();
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Puts what `next` has into `current`, in its place: everything that holds `current` has the new version
|
|
1850
|
+
*
|
|
1851
|
+
* @beta
|
|
1852
|
+
*/
|
|
1853
|
+
function replaceIn(current, next) {
|
|
1854
|
+
for (const key of Object.keys(current)) if (!(key in next)) delete current[key];
|
|
1855
|
+
Object.assign(current, next);
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* A game's entry module's new version, taken while it runs (hot reload). At the module's end, the call to Vite written
|
|
1859
|
+
* out as it is (Vite finds a module that takes its own new versions by it):
|
|
1860
|
+
*
|
|
1861
|
+
* if (import.meta.hot) import.meta.hot.accept((module) => hotRules(import.meta.hot, melee, module?.melee))
|
|
1862
|
+
*
|
|
1863
|
+
* As it or a file it reads (its units, its `data.json`) is saved in the dev server, the module given is made the new
|
|
1864
|
+
* one in place, so that what holds it has the new one, and the games and editors of the page playing it take its
|
|
1865
|
+
* definitions and rules (`reloadGame`, through `onNewGameVersion`). What does not go loads the page again, saying why
|
|
1866
|
+
* (`hot.invalidate(reason)`): no new module, another game, a component defined another way, or a change to what a
|
|
1867
|
+
* running world keeps as it was made (its `systems`, `hooks`, `components`, `resources`, and `withoutSystems` and
|
|
1868
|
+
* `withoutHooks`, compared by their code and values). No client is pulled into the rules, which a server reads too
|
|
1869
|
+
*
|
|
1870
|
+
* @beta
|
|
1871
|
+
*/
|
|
1872
|
+
function hotRules(hot, current, next) {
|
|
1873
|
+
const game = next;
|
|
1874
|
+
if (!game || typeof game !== "object" || game.id !== current.id) return hot?.invalidate(`${current.id}: its module is not there as it was`);
|
|
1875
|
+
const kept = NOT_RELOADED.filter((key) => shapeOf(current[key]) !== shapeOf(game[key]));
|
|
1876
|
+
if (kept.length) return hot?.invalidate(`${current.id}: its ${kept.join(", ")} changed, which a running game does not take`);
|
|
1877
|
+
try {
|
|
1878
|
+
replaceIn(current, game);
|
|
1879
|
+
for (const listener of listeners) listener(current);
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
rootLogger.warn("game", `${current.id}'s new version could not be taken while it runs: the page loads again`, { error: String(error) });
|
|
1882
|
+
hot?.invalidate();
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
//#endregion
|
|
1886
|
+
//#region src/session/worker.ts
|
|
1887
|
+
/** Milliseconds between two words of where the time went */
|
|
1888
|
+
var TIMING_EVERY = 1e3;
|
|
1889
|
+
var firstOfMap = (map) => (map.players.find((player) => player.start) ?? map.players[0])?.id;
|
|
1890
|
+
function firstOfWorld(world) {
|
|
1891
|
+
for (const player of world.players.values()) if (!player.neutral && !player.creeps) return player.id;
|
|
1892
|
+
throw new Error("The world has no player to play: every one of its players is neutral");
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* What a game's worker file does: `serveWorld(game, { start })`. It waits for the page to say what to play (a map, a
|
|
1896
|
+
* save, or nothing for a world made here in code), makes the world, runs it by the clock, and seats the page's player
|
|
1897
|
+
* at it. A new game asked for ends the one before: a restart is the same worker, its navigation built already
|
|
1898
|
+
*
|
|
1899
|
+
* @beta
|
|
1900
|
+
*/
|
|
1901
|
+
function serveWorld(first, options = {}) {
|
|
1902
|
+
/** The game's version now: the one it began with, or the one it took last (`reload`) */
|
|
1903
|
+
let game = first;
|
|
1904
|
+
const port = options.port ?? globalThis;
|
|
1905
|
+
const post = (message, transfer) => port.postMessage(message, transfer);
|
|
1906
|
+
/** The game running now, and what ends it */
|
|
1907
|
+
let running;
|
|
1908
|
+
/** Whether the systems are measured, as the page's stats ask: off until they do */
|
|
1909
|
+
let profiling = false;
|
|
1910
|
+
/** Each start has a number: one overtaken while its world was made is let go */
|
|
1911
|
+
let starts = 0;
|
|
1912
|
+
const start = async (asked) => {
|
|
1913
|
+
const mine = ++starts;
|
|
1914
|
+
running?.end();
|
|
1915
|
+
running = void 0;
|
|
1916
|
+
const log = new Logger();
|
|
1917
|
+
const stopLog = log.to((entry) => post({
|
|
1918
|
+
vg: "log",
|
|
1919
|
+
entry
|
|
1920
|
+
}));
|
|
1921
|
+
let world;
|
|
1922
|
+
try {
|
|
1923
|
+
const seed = asked.seed ?? randomSeed();
|
|
1924
|
+
world = asked.from ? await loadWorld(asked.from, {
|
|
1925
|
+
game,
|
|
1926
|
+
log
|
|
1927
|
+
}) : asked.map ? await createWorldFromMap({
|
|
1928
|
+
map: asked.map,
|
|
1929
|
+
game,
|
|
1930
|
+
seed,
|
|
1931
|
+
log
|
|
1932
|
+
}) : options.world ? await options.world({
|
|
1933
|
+
rules: game,
|
|
1934
|
+
seed,
|
|
1935
|
+
args: asked.args,
|
|
1936
|
+
log
|
|
1937
|
+
}) : void 0;
|
|
1938
|
+
if (!world) throw new Error("A game in a worker needs a map, a save to go on from, or a world made in the worker (serveWorld world)");
|
|
1939
|
+
if (mine !== starts) return world.dispose();
|
|
1940
|
+
world.errorPolicy ??= "end";
|
|
1941
|
+
const player = asked.player ?? options.player?.(world, asked.map) ?? (asked.map && firstOfMap(asked.map)) ?? firstOfWorld(world);
|
|
1942
|
+
if (!asked.from) await options.start?.(world, {
|
|
1943
|
+
player,
|
|
1944
|
+
map: asked.map,
|
|
1945
|
+
args: asked.args
|
|
1946
|
+
});
|
|
1947
|
+
if (mine !== starts) return world.dispose();
|
|
1948
|
+
if (asked.reveal) world.fog.setRevealed(world.player(player).team, true);
|
|
1949
|
+
if (asked.debug) world.debugPlayers.add(player);
|
|
1950
|
+
const made = world;
|
|
1951
|
+
const host = new SessionHost(made, {
|
|
1952
|
+
ownsWorld: true,
|
|
1953
|
+
sendRate: made.tickRate,
|
|
1954
|
+
dropSilentAfter: Infinity
|
|
1955
|
+
});
|
|
1956
|
+
let hear = () => {};
|
|
1957
|
+
const transport = {
|
|
1958
|
+
send: (text) => post(text),
|
|
1959
|
+
onMessage: (handler) => hear = handler,
|
|
1960
|
+
onClose: () => {},
|
|
1961
|
+
close: () => {}
|
|
1962
|
+
};
|
|
1963
|
+
const stopCheckpoint = made.events.on(checkpoint, ({ label }) => {
|
|
1964
|
+
setTimeout(() => {
|
|
1965
|
+
if (running?.host === host) post({
|
|
1966
|
+
vg: "checkpoint",
|
|
1967
|
+
label,
|
|
1968
|
+
save: saveWorld(made)
|
|
1969
|
+
});
|
|
1970
|
+
}, 0);
|
|
1971
|
+
});
|
|
1972
|
+
made.scheduler.profiling = profiling;
|
|
1973
|
+
const timingTimer = setInterval(() => {
|
|
1974
|
+
const timing = host.takeTiming();
|
|
1975
|
+
post({
|
|
1976
|
+
vg: "timing",
|
|
1977
|
+
tick: timing.ticks ? timing.tickTime / timing.ticks : 0,
|
|
1978
|
+
tickMax: timing.tickMax,
|
|
1979
|
+
send: timing.sends ? timing.sendTime / timing.sends : 0,
|
|
1980
|
+
systems: made.scheduler.timing()
|
|
1981
|
+
});
|
|
1982
|
+
}, TIMING_EVERY);
|
|
1983
|
+
running = {
|
|
1984
|
+
host,
|
|
1985
|
+
hear: (text) => hear(text),
|
|
1986
|
+
end: () => {
|
|
1987
|
+
clearInterval(timingTimer);
|
|
1988
|
+
stopCheckpoint();
|
|
1989
|
+
stopLog();
|
|
1990
|
+
host.dispose();
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
post({
|
|
1994
|
+
vg: "ready",
|
|
1995
|
+
player
|
|
1996
|
+
});
|
|
1997
|
+
host.connect(player, transport);
|
|
1998
|
+
host.start();
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
stopLog();
|
|
2001
|
+
world?.dispose();
|
|
2002
|
+
if (mine === starts) post({
|
|
2003
|
+
vg: "refused",
|
|
2004
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2005
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
};
|
|
2009
|
+
const heard = (message) => {
|
|
2010
|
+
if (typeof message === "string") return running?.hear(message);
|
|
2011
|
+
const said = message;
|
|
2012
|
+
if (typeof said !== "object" || said === null) return;
|
|
2013
|
+
switch (said.vg) {
|
|
2014
|
+
case "start":
|
|
2015
|
+
start(said);
|
|
2016
|
+
return;
|
|
2017
|
+
case "reload":
|
|
2018
|
+
(async () => {
|
|
2019
|
+
const world = running?.host.world;
|
|
2020
|
+
if (!options.reload) {
|
|
2021
|
+
world?.log.warn("game", `${game.id} in a worker has no reload (serveWorld reload): it keeps the version it began with`);
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
const next = await options.reload(Date.now());
|
|
2026
|
+
if (!next) return;
|
|
2027
|
+
game = next;
|
|
2028
|
+
if (world && running?.host.world === world) reloadGame(world, next);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
world?.log.error("game", `${game.id}'s new version could not be taken in the worker: ${error instanceof Error ? error.message : String(error)}`);
|
|
2031
|
+
}
|
|
2032
|
+
})();
|
|
2033
|
+
return;
|
|
2034
|
+
case "stop":
|
|
2035
|
+
starts++;
|
|
2036
|
+
running?.end();
|
|
2037
|
+
running = void 0;
|
|
2038
|
+
return;
|
|
2039
|
+
case "speed":
|
|
2040
|
+
if (running) running.host.speed = said.speed;
|
|
2041
|
+
return;
|
|
2042
|
+
case "advance": return running?.host.advance(said.ticks);
|
|
2043
|
+
case "save":
|
|
2044
|
+
try {
|
|
2045
|
+
if (!running) throw new Error("There is no game to save");
|
|
2046
|
+
post({
|
|
2047
|
+
vg: "saved",
|
|
2048
|
+
id: said.id,
|
|
2049
|
+
save: saveWorld(running.host.world)
|
|
2050
|
+
});
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
post({
|
|
2053
|
+
vg: "saved",
|
|
2054
|
+
id: said.id,
|
|
2055
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
return;
|
|
2059
|
+
case "navigation":
|
|
2060
|
+
navigationData(said.build).then((data) => post({
|
|
2061
|
+
vg: "built",
|
|
2062
|
+
id: said.id,
|
|
2063
|
+
data
|
|
2064
|
+
}), (error) => post({
|
|
2065
|
+
vg: "built",
|
|
2066
|
+
id: said.id,
|
|
2067
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2068
|
+
}));
|
|
2069
|
+
return;
|
|
2070
|
+
case "profile":
|
|
2071
|
+
profiling = said.on;
|
|
2072
|
+
if (running) running.host.world.scheduler.profiling = said.on;
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
if (port.on) port.on("message", heard);
|
|
2077
|
+
else port.addEventListener?.("message", (event) => heard(event.data));
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* A seat at a game run in a worker of this page (`workerSession`): a `RemoteSession` whose host is a message away,
|
|
2081
|
+
* with what a game played in the page has besides: its pace held and stepped (`speed`, `advance`), a save of it
|
|
2082
|
+
* (`save`), the checkpoint to start again from, what it says in its log, the debug tools' view, and where its time went
|
|
2083
|
+
*
|
|
2084
|
+
* @beta
|
|
2085
|
+
*/
|
|
2086
|
+
var WorkerSession = class extends RemoteSession {
|
|
2087
|
+
/** What the world says (`World.log`), said again here: the page shows it with its own */
|
|
2088
|
+
log;
|
|
2089
|
+
checkpoint;
|
|
2090
|
+
/** The debug tools' view of the world, where they are on: over the session's own words, as a server's is */
|
|
2091
|
+
get debug() {
|
|
2092
|
+
return this.debugView;
|
|
2093
|
+
}
|
|
2094
|
+
/** Its pace held and stepped, saves of it, the debug tools' view where they are on, and where its time went and in which systems */
|
|
2095
|
+
can = {
|
|
2096
|
+
pace: paceOf(this),
|
|
2097
|
+
saves: savesOf(this),
|
|
2098
|
+
inspect: this.debugView,
|
|
2099
|
+
timing: {
|
|
2100
|
+
timing: () => this.timing(),
|
|
2101
|
+
profile: (on) => this.postToWorker({
|
|
2102
|
+
vg: "profile",
|
|
2103
|
+
on
|
|
2104
|
+
})
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
pace = 1;
|
|
2108
|
+
timed;
|
|
2109
|
+
/** Milliseconds the page took to take in what the worker sent, each time, evened out: its share of a game run there */
|
|
2110
|
+
received = 0;
|
|
2111
|
+
postToWorker = () => {};
|
|
2112
|
+
saves = /* @__PURE__ */ new Map();
|
|
2113
|
+
nextSave = 0;
|
|
2114
|
+
stopHearing = () => {};
|
|
2115
|
+
/** What `workerSession` gives it once it is made: the way to the worker, and what stops hearing it */
|
|
2116
|
+
wire(post, log, stop) {
|
|
2117
|
+
this.postToWorker = post;
|
|
2118
|
+
this.log = log;
|
|
2119
|
+
this.stopHearing = stop;
|
|
2120
|
+
}
|
|
2121
|
+
/** What the worker said that is not the session's own text */
|
|
2122
|
+
heard(said) {
|
|
2123
|
+
if (said.vg === "checkpoint") this.checkpoint = {
|
|
2124
|
+
label: said.label,
|
|
2125
|
+
save: said.save
|
|
2126
|
+
};
|
|
2127
|
+
else if (said.vg === "timing") this.timed = {
|
|
2128
|
+
tick: said.tick,
|
|
2129
|
+
tickMax: said.tickMax,
|
|
2130
|
+
send: said.send,
|
|
2131
|
+
systems: said.systems
|
|
2132
|
+
};
|
|
2133
|
+
else if (said.vg === "saved") {
|
|
2134
|
+
const waiting = this.saves.get(said.id);
|
|
2135
|
+
this.saves.delete(said.id);
|
|
2136
|
+
if (said.save) waiting?.done(said.save);
|
|
2137
|
+
else waiting?.failed(new Error(said.error ?? "The game could not be saved"));
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* How far behind the newest tick the screen is shown, in ticks: one, and room for how unevenly they come (a tick
|
|
2142
|
+
* that took long comes late). Less than over a wire, where two are kept in hand: there is no line to stall here
|
|
2143
|
+
*/
|
|
2144
|
+
get delay() {
|
|
2145
|
+
return this.interval + Math.min(this.jitter, this.limits.maxJitter ?? .2) * this.tickRate * 2;
|
|
2146
|
+
}
|
|
2147
|
+
get speed() {
|
|
2148
|
+
return this.pace;
|
|
2149
|
+
}
|
|
2150
|
+
set speed(speed) {
|
|
2151
|
+
this.pace = speed;
|
|
2152
|
+
this.postToWorker({
|
|
2153
|
+
vg: "speed",
|
|
2154
|
+
speed
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
advance(ticks = 1) {
|
|
2158
|
+
this.postToWorker({
|
|
2159
|
+
vg: "advance",
|
|
2160
|
+
ticks
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Asks the worker to take a new version of the game (hot reload): it loads the game again itself, as its file says
|
|
2165
|
+
* (`serveWorld` `reload`), and puts it into its world
|
|
2166
|
+
*/
|
|
2167
|
+
reloadGame() {
|
|
2168
|
+
this.postToWorker({ vg: "reload" });
|
|
2169
|
+
}
|
|
2170
|
+
save() {
|
|
2171
|
+
return new Promise((done, failed) => {
|
|
2172
|
+
const id = ++this.nextSave;
|
|
2173
|
+
this.saves.set(id, {
|
|
2174
|
+
done,
|
|
2175
|
+
failed
|
|
2176
|
+
});
|
|
2177
|
+
this.postToWorker({
|
|
2178
|
+
vg: "save",
|
|
2179
|
+
id
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
timing() {
|
|
2184
|
+
return this.timed && {
|
|
2185
|
+
...this.timed,
|
|
2186
|
+
receive: this.received
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
/** Ends the game in the worker. The worker is the page's: it stays, for the next game, until the page ends it */
|
|
2190
|
+
dispose() {
|
|
2191
|
+
this.postToWorker({ vg: "stop" });
|
|
2192
|
+
this.stopHearing();
|
|
2193
|
+
for (const waiting of this.saves.values()) waiting.failed(/* @__PURE__ */ new Error("The game ended"));
|
|
2194
|
+
this.saves.clear();
|
|
2195
|
+
super.dispose();
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
/**
|
|
2199
|
+
* Plays a game in a worker, and seats this page's player at it: the worker's file calls `serveWorld` with the same
|
|
2200
|
+
* game. Settles once the world is made there and its welcome is in; fails with what the worker said went wrong
|
|
2201
|
+
*
|
|
2202
|
+
* const session = await workerSession(new Worker(new URL('./game.worker.ts', import.meta.url), { type: 'module' }), { game, map })
|
|
2203
|
+
*
|
|
2204
|
+
* @beta
|
|
2205
|
+
*/
|
|
2206
|
+
async function workerSession(worker, options) {
|
|
2207
|
+
const { game, navigation, maps, log: under, ...asked } = options;
|
|
2208
|
+
let toSession = () => {};
|
|
2209
|
+
let closed = () => {};
|
|
2210
|
+
let session;
|
|
2211
|
+
const builds = /* @__PURE__ */ new Map();
|
|
2212
|
+
let nextBuild = 0;
|
|
2213
|
+
let saying;
|
|
2214
|
+
const log = (under ?? rootLogger).child({ context: () => ({
|
|
2215
|
+
game: saying?.game,
|
|
2216
|
+
tick: saying?.tick
|
|
2217
|
+
}) });
|
|
2218
|
+
const post = (message) => worker.postMessage(message);
|
|
2219
|
+
let refuse = () => {};
|
|
2220
|
+
const refused = new Promise((_, reject) => refuse = reject);
|
|
2221
|
+
refused.catch(() => {});
|
|
2222
|
+
const heard = (message) => {
|
|
2223
|
+
if (typeof message === "string") {
|
|
2224
|
+
const began = performance.now();
|
|
2225
|
+
toSession(message);
|
|
2226
|
+
if (session) session.received += (performance.now() - began - session.received) * .1;
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
const said = message;
|
|
2230
|
+
if (typeof said !== "object" || said === null) return;
|
|
2231
|
+
if (said.vg === "refused") {
|
|
2232
|
+
const error = new Error(said.message);
|
|
2233
|
+
if (said.stack) error.stack = said.stack;
|
|
2234
|
+
refuse(error);
|
|
2235
|
+
} else if (said.vg === "log") {
|
|
2236
|
+
saying = said.entry;
|
|
2237
|
+
log.log(said.entry.level, said.entry.category, said.entry.message, said.entry.fields);
|
|
2238
|
+
saying = void 0;
|
|
2239
|
+
} else if (said.vg === "built") {
|
|
2240
|
+
const waiting = builds.get(said.id);
|
|
2241
|
+
builds.delete(said.id);
|
|
2242
|
+
if (said.data) waiting?.done(said.data);
|
|
2243
|
+
else waiting?.failed(new Error(said.error ?? "The navigation could not be built"));
|
|
2244
|
+
} else session?.heard(said);
|
|
2245
|
+
};
|
|
2246
|
+
const broke = (error) => {
|
|
2247
|
+
const reason = /* @__PURE__ */ new Error(`The game's worker stopped: ${error instanceof Error ? error.message : String(error?.message ?? error)}`);
|
|
2248
|
+
log.error("game", reason.message, { error: errorFields(error) });
|
|
2249
|
+
refuse(reason);
|
|
2250
|
+
closed();
|
|
2251
|
+
};
|
|
2252
|
+
const onMessage = (event) => heard(event.data);
|
|
2253
|
+
if (worker.on) {
|
|
2254
|
+
worker.on("message", heard);
|
|
2255
|
+
worker.on("error", broke);
|
|
2256
|
+
} else {
|
|
2257
|
+
worker.addEventListener?.("message", onMessage);
|
|
2258
|
+
worker.addEventListener?.("error", broke);
|
|
2259
|
+
}
|
|
2260
|
+
const stop = () => {
|
|
2261
|
+
if (worker.off) {
|
|
2262
|
+
worker.off("message", heard);
|
|
2263
|
+
worker.off("error", broke);
|
|
2264
|
+
} else {
|
|
2265
|
+
worker.removeEventListener?.("message", onMessage);
|
|
2266
|
+
worker.removeEventListener?.("error", broke);
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
const transport = {
|
|
2270
|
+
send: (text) => post(text),
|
|
2271
|
+
onMessage: (handler) => toSession = handler,
|
|
2272
|
+
onClose: (handler) => closed = handler,
|
|
2273
|
+
close: () => {}
|
|
2274
|
+
};
|
|
2275
|
+
post({
|
|
2276
|
+
vg: "start",
|
|
2277
|
+
...asked
|
|
2278
|
+
});
|
|
2279
|
+
try {
|
|
2280
|
+
session = await Promise.race([connectAs(transport, {
|
|
2281
|
+
game,
|
|
2282
|
+
maps,
|
|
2283
|
+
navigation: {
|
|
2284
|
+
...navigation,
|
|
2285
|
+
builds: { builder: (build) => new Promise((done, failed) => {
|
|
2286
|
+
const id = ++nextBuild;
|
|
2287
|
+
builds.set(id, {
|
|
2288
|
+
done,
|
|
2289
|
+
failed
|
|
2290
|
+
});
|
|
2291
|
+
post({
|
|
2292
|
+
vg: "navigation",
|
|
2293
|
+
id,
|
|
2294
|
+
build: {
|
|
2295
|
+
positions: build.positions.slice(),
|
|
2296
|
+
indices: build.indices.slice(),
|
|
2297
|
+
config: build.config,
|
|
2298
|
+
...build.speeds && { speeds: {
|
|
2299
|
+
...build.speeds,
|
|
2300
|
+
values: build.speeds.values.slice()
|
|
2301
|
+
} }
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2304
|
+
}) }
|
|
2305
|
+
}
|
|
2306
|
+
}, (...made) => new WorkerSession(...made)), refused]);
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
post({ vg: "stop" });
|
|
2309
|
+
stop();
|
|
2310
|
+
throw error;
|
|
2311
|
+
}
|
|
2312
|
+
session.wire(post, log, stop);
|
|
2313
|
+
return session;
|
|
2314
|
+
}
|
|
2315
|
+
//#endregion
|
|
2316
|
+
//#region src/session/socket.ts
|
|
2317
|
+
/**
|
|
2318
|
+
* The player's end of a connection to a server, over a WebSocket: what `connect` and a server's lobby talk through.
|
|
2319
|
+
* Resolves once the connection is open; fails when it cannot be made
|
|
2320
|
+
*
|
|
2321
|
+
* @beta
|
|
2322
|
+
*/
|
|
2323
|
+
function openSocket(url) {
|
|
2324
|
+
return new Promise((resolve, reject) => {
|
|
2325
|
+
const socket = new WebSocket(url);
|
|
2326
|
+
let handler = () => {};
|
|
2327
|
+
let closed = () => {};
|
|
2328
|
+
socket.onmessage = (event) => handler(String(event.data));
|
|
2329
|
+
socket.onclose = () => closed();
|
|
2330
|
+
socket.onerror = () => reject(/* @__PURE__ */ new Error(`Could not connect to ${url}`));
|
|
2331
|
+
socket.onopen = () => resolve({
|
|
2332
|
+
send: (message) => {
|
|
2333
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(message);
|
|
2334
|
+
},
|
|
2335
|
+
onMessage: (next) => handler = next,
|
|
2336
|
+
onClose: (next) => closed = next,
|
|
2337
|
+
close: () => socket.close()
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
//#endregion
|
|
2342
|
+
//#region src/session/share.ts
|
|
2343
|
+
/** How often the host looks whether the world went on, a second: it sends at its own rate */
|
|
2344
|
+
var LOOKS$2 = 30;
|
|
2345
|
+
/**
|
|
2346
|
+
* Shares the page's world through a relay, for others to watch. Fails when the relay cannot be reached
|
|
2347
|
+
*
|
|
2348
|
+
* @beta
|
|
2349
|
+
*/
|
|
2350
|
+
async function shareGame(world, options) {
|
|
2351
|
+
const relay = await openSocket(options.url);
|
|
2352
|
+
const host = new SessionHost(world, { sendRate: options.sendRate });
|
|
2353
|
+
/** Each watcher's end of the connection, as the host holds it: what the relay brings from them goes to it */
|
|
2354
|
+
const ends = /* @__PURE__ */ new Map();
|
|
2355
|
+
let state = "shared";
|
|
2356
|
+
let changed = () => {};
|
|
2357
|
+
const ask = (request) => relay.send(JSON.stringify(request));
|
|
2358
|
+
const seat = (id) => {
|
|
2359
|
+
const end = {
|
|
2360
|
+
message: (_text) => {},
|
|
2361
|
+
closed: () => {}
|
|
2362
|
+
};
|
|
2363
|
+
ends.set(id, end);
|
|
2364
|
+
host.connect(options.player, {
|
|
2365
|
+
send: (text) => ask({
|
|
2366
|
+
type: "send",
|
|
2367
|
+
to: id,
|
|
2368
|
+
text
|
|
2369
|
+
}),
|
|
2370
|
+
onMessage: (handler) => end.message = handler,
|
|
2371
|
+
onClose: (handler) => end.closed = handler,
|
|
2372
|
+
close: () => {}
|
|
2373
|
+
}, {
|
|
2374
|
+
readOnly: true,
|
|
2375
|
+
observer: true
|
|
2376
|
+
});
|
|
2377
|
+
};
|
|
2378
|
+
let last = performance.now();
|
|
2379
|
+
const timer = setInterval(() => {
|
|
2380
|
+
const now = performance.now();
|
|
2381
|
+
host.publish((now - last) / 1e3);
|
|
2382
|
+
last = now;
|
|
2383
|
+
}, 1e3 / LOOKS$2);
|
|
2384
|
+
/** Over once: closed, or never shared as the key was taken */
|
|
2385
|
+
const stop = (next = "closed") => {
|
|
2386
|
+
if (state !== "shared") return;
|
|
2387
|
+
clearInterval(timer);
|
|
2388
|
+
host.dispose();
|
|
2389
|
+
ends.clear();
|
|
2390
|
+
state = next;
|
|
2391
|
+
changed();
|
|
2392
|
+
};
|
|
2393
|
+
relay.onMessage((text) => {
|
|
2394
|
+
const notice = JSON.parse(text);
|
|
2395
|
+
if (notice.type === "taken") {
|
|
2396
|
+
stop("taken");
|
|
2397
|
+
relay.close();
|
|
2398
|
+
} else if (notice.type === "watcher") {
|
|
2399
|
+
seat(notice.id);
|
|
2400
|
+
changed();
|
|
2401
|
+
} else if (notice.type === "from") ends.get(notice.watcher)?.message(notice.text);
|
|
2402
|
+
else if (notice.type === "gone") {
|
|
2403
|
+
ends.get(notice.watcher)?.closed();
|
|
2404
|
+
ends.delete(notice.watcher);
|
|
2405
|
+
changed();
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
relay.onClose(() => stop());
|
|
2409
|
+
ask({
|
|
2410
|
+
type: "share",
|
|
2411
|
+
key: options.key,
|
|
2412
|
+
name: options.name
|
|
2413
|
+
});
|
|
2414
|
+
return {
|
|
2415
|
+
get state() {
|
|
2416
|
+
return state;
|
|
2417
|
+
},
|
|
2418
|
+
get watchers() {
|
|
2419
|
+
return ends.size;
|
|
2420
|
+
},
|
|
2421
|
+
onChange(handler) {
|
|
2422
|
+
changed = handler;
|
|
2423
|
+
},
|
|
2424
|
+
close() {
|
|
2425
|
+
stop();
|
|
2426
|
+
relay.close();
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
//#endregion
|
|
2431
|
+
//#region src/generateWorker.ts
|
|
2432
|
+
/**
|
|
2433
|
+
* What a worker's file of a game's maps does: it makes, with the game's generator (`GameModule.generator`) and its
|
|
2434
|
+
* registry, the map of each set of options it is sent, and sends it back. A worker's file of two lines, the game's rules
|
|
2435
|
+
* imported and this called with them; `port` is where it hears, as for `serveNavigationBuilds`: the worker's own scope
|
|
2436
|
+
* when left out
|
|
2437
|
+
*
|
|
2438
|
+
* @beta
|
|
2439
|
+
*/
|
|
2440
|
+
function serveMapGenerator(rules, port = globalThis) {
|
|
2441
|
+
let registry;
|
|
2442
|
+
const asked = (message) => {
|
|
2443
|
+
const { id, options } = message;
|
|
2444
|
+
try {
|
|
2445
|
+
if (!rules.generator) throw new Error(`${rules.name} has no generator of maps (GameModule.generator)`);
|
|
2446
|
+
registry ??= createRegistry(rules);
|
|
2447
|
+
port.postMessage({
|
|
2448
|
+
id,
|
|
2449
|
+
made: generateMap(rules.generator, options, registry)
|
|
2450
|
+
});
|
|
2451
|
+
} catch (error) {
|
|
2452
|
+
port.postMessage({
|
|
2453
|
+
id,
|
|
2454
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
if (port.on) port.on("message", asked);
|
|
2459
|
+
else port.addEventListener?.("message", (event) => asked(event.data));
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Maps made in a worker whose file serves them (`serveMapGenerator`): the page makes the worker as its bundler wants it,
|
|
2463
|
+
* `mapGeneratorWorker(() => new Worker(new URL('./generate.worker.ts', import.meta.url), { type: 'module' }))`. Given
|
|
2464
|
+
* as a function, the worker is made at the first map asked for: a menu that is never opened fetches none of it
|
|
2465
|
+
*
|
|
2466
|
+
* @beta
|
|
2467
|
+
*/
|
|
2468
|
+
function mapGeneratorWorker(given) {
|
|
2469
|
+
let next = 0;
|
|
2470
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
2471
|
+
const answered = (answer) => {
|
|
2472
|
+
const wait = waiting.get(answer.id);
|
|
2473
|
+
if (!wait) return;
|
|
2474
|
+
waiting.delete(answer.id);
|
|
2475
|
+
if (answer.made) wait.done(answer.made);
|
|
2476
|
+
else wait.failed(new Error(answer.error ?? "The map could not be made"));
|
|
2477
|
+
};
|
|
2478
|
+
const broke = (error) => {
|
|
2479
|
+
const reason = /* @__PURE__ */ new Error(`The maps' worker stopped: ${error instanceof Error ? error.message : String(error?.message ?? error)}`);
|
|
2480
|
+
for (const wait of waiting.values()) wait.failed(reason);
|
|
2481
|
+
waiting.clear();
|
|
2482
|
+
};
|
|
2483
|
+
let made;
|
|
2484
|
+
const worker = () => {
|
|
2485
|
+
if (made) return made;
|
|
2486
|
+
made = typeof given === "function" ? given() : given;
|
|
2487
|
+
if (made.on) {
|
|
2488
|
+
made.on("message", (value) => answered(value));
|
|
2489
|
+
made.on("error", broke);
|
|
2490
|
+
} else {
|
|
2491
|
+
made.addEventListener?.("message", (event) => answered(event.data));
|
|
2492
|
+
made.addEventListener?.("error", broke);
|
|
2493
|
+
}
|
|
2494
|
+
return made;
|
|
2495
|
+
};
|
|
2496
|
+
if (typeof given !== "function") worker();
|
|
2497
|
+
return (options) => new Promise((done, failed) => {
|
|
2498
|
+
const id = ++next;
|
|
2499
|
+
waiting.set(id, {
|
|
2500
|
+
done,
|
|
2501
|
+
failed
|
|
2502
|
+
});
|
|
2503
|
+
worker().postMessage({
|
|
2504
|
+
id,
|
|
2505
|
+
options
|
|
2506
|
+
});
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
//#endregion
|
|
2510
|
+
//#region src/rulesCheck.ts
|
|
2511
|
+
/**
|
|
2512
|
+
* Checks a game's rules against themselves (`RulesProblem`): run by `defineGame` with the view's checks, and in a
|
|
2513
|
+
* game's tests (`expect(checkRules(rules).filter((p) => p.level === 'error')).toEqual([])`). Nothing is thrown
|
|
2514
|
+
*
|
|
2515
|
+
* @beta
|
|
2516
|
+
*/
|
|
2517
|
+
function checkRules(rules) {
|
|
2518
|
+
const problems = [];
|
|
2519
|
+
const ids = (list) => new Set((list ?? []).map((def) => def.id));
|
|
2520
|
+
const units = ids(rules.units);
|
|
2521
|
+
const abilities = ids(rules.abilities);
|
|
2522
|
+
const buffs = ids(rules.buffs);
|
|
2523
|
+
const items = ids(rules.items);
|
|
2524
|
+
const upgrades = ids(rules.upgrades);
|
|
2525
|
+
const resources = ids(rules.resources);
|
|
2526
|
+
/** The types of effect something handles: the engine's own, and the game's (`GameModule.effects`) */
|
|
2527
|
+
const handled = new Set([
|
|
2528
|
+
...coreEffects,
|
|
2529
|
+
...summonEffects,
|
|
2530
|
+
...dispelEffects,
|
|
2531
|
+
...rules.effects ?? []
|
|
2532
|
+
].map((kind) => kind.type));
|
|
2533
|
+
const effectTypes = (type) => handled.has(type);
|
|
2534
|
+
const known = (where, id, set, what) => {
|
|
2535
|
+
if (id === void 0 || set.has(id)) return;
|
|
2536
|
+
const meant = nearestId(id, set);
|
|
2537
|
+
problems.push({
|
|
2538
|
+
level: "error",
|
|
2539
|
+
where,
|
|
2540
|
+
id,
|
|
2541
|
+
message: `${where} names "${id}", which is no ${what} of the rules${meant ? `. Did you mean "${meant}"?` : ""}`
|
|
2542
|
+
});
|
|
2543
|
+
};
|
|
2544
|
+
/** A number that does not do what it is there for: said as a warning, the engine making do with it */
|
|
2545
|
+
const odd = (where, id, message) => problems.push({
|
|
2546
|
+
level: "warning",
|
|
2547
|
+
where,
|
|
2548
|
+
id,
|
|
2549
|
+
message
|
|
2550
|
+
});
|
|
2551
|
+
/** Whether a number, or any of its levels (`Scaled`), is under a bound: at `least`, or above it where `above` */
|
|
2552
|
+
const under = (value, least, above = false) => value !== void 0 && (Array.isArray(value) ? value : [value]).some((each) => above ? each <= least : each < least);
|
|
2553
|
+
const all = (where, list, set, what) => list?.forEach((id, index) => known(`${where}[${index}]`, id, set, what));
|
|
2554
|
+
/** What a requirement names: a unit type (a building to have) or an upgrade */
|
|
2555
|
+
const requirable = /* @__PURE__ */ new Set([...units, ...upgrades]);
|
|
2556
|
+
for (const unit of rules.units) {
|
|
2557
|
+
const at = `units.${unit.id}`;
|
|
2558
|
+
all(`${at}.trains`, unit.trains, units, "unit type");
|
|
2559
|
+
all(`${at}.builds`, unit.builds, units, "unit type");
|
|
2560
|
+
all(`${at}.hires`, unit.hires, units, "unit type");
|
|
2561
|
+
all(`${at}.researches`, unit.researches, upgrades, "upgrade");
|
|
2562
|
+
all(`${at}.requires`, unit.requires, requirable, "unit type or upgrade");
|
|
2563
|
+
all(`${at}.sells`, unit.sells, items, "item");
|
|
2564
|
+
all(`${at}.abilities`, unit.abilities, abilities, "ability");
|
|
2565
|
+
if (typeof unit.upgradesTo === "string") known(`${at}.upgradesTo`, unit.upgradesTo, units, "unit type");
|
|
2566
|
+
else all(`${at}.upgradesTo`, unit.upgradesTo, units, "unit type");
|
|
2567
|
+
if (Array.isArray(unit.drops)) unit.drops.forEach((drop, index) => known(`${at}.drops[${index}].item`, drop.item, items, "item"));
|
|
2568
|
+
if (unit.hero) {
|
|
2569
|
+
all(`${at}.hero.learns`, unit.hero.learns, abilities, "ability");
|
|
2570
|
+
all(`${at}.hero.spells`, unit.hero.spells, abilities, "ability");
|
|
2571
|
+
known(`${at}.hero.meter`, unit.hero.meter, buffs, "buff");
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
const effects = (where, list) => {
|
|
2575
|
+
list?.map(anyEffect).forEach((effect, index) => {
|
|
2576
|
+
const at = `${where}[${index}]`;
|
|
2577
|
+
if (!effectTypes(effect.type)) problems.push({
|
|
2578
|
+
level: "error",
|
|
2579
|
+
where: `${at}.type`,
|
|
2580
|
+
id: effect.type,
|
|
2581
|
+
message: `${at} is of the type "${effect.type}", which nothing handles (GameModule.effects)`
|
|
2582
|
+
});
|
|
2583
|
+
known(`${at}.buff`, effect.buff, buffs, "buff");
|
|
2584
|
+
known(`${at}.ifBuff`, effect.ifBuff, buffs, "buff");
|
|
2585
|
+
known(`${at}.whileBuff`, effect.whileBuff, buffs, "buff");
|
|
2586
|
+
known(`${at}.unit`, effect.unit, units, "unit type");
|
|
2587
|
+
known(`${at}.ability`, effect.ability, abilities, "ability");
|
|
2588
|
+
known(`${at}.sell`, effect.sell, resources, "resource");
|
|
2589
|
+
known(`${at}.buy`, effect.buy, resources, "resource");
|
|
2590
|
+
for (const field of ["arc", "bounce"]) if (effect[field] && effect.type !== "missile") odd(`${at}.${field}`, effect.type, `${at}.${field} is read of a missile alone, not of ${effect.type}`);
|
|
2591
|
+
if (effect.arc && !(effect.arc.height > 0)) odd(`${at}.arc.height`, effect.type, `${at}.arc.height is not above 0: it is thrown flat`);
|
|
2592
|
+
if (effect.arc?.time !== void 0 && !(effect.arc.time > 0)) odd(`${at}.arc.time`, effect.type, `${at}.arc.time is not above 0: it flies at its speed`);
|
|
2593
|
+
if (effect.bounce && under(effect.bounce.count, 1)) odd(`${at}.bounce.count`, effect.type, `${at}.bounce.count is under 1 at a level: it does not spring on there`);
|
|
2594
|
+
if (effect.bounce && !(effect.bounce.range > 0)) odd(`${at}.bounce.range`, effect.type, `${at}.bounce.range is not above 0: it finds nothing to spring to`);
|
|
2595
|
+
effects(`${at}.effects`, effect.effects);
|
|
2596
|
+
effects(`${at}.onEnd`, effect.onEnd);
|
|
2597
|
+
effects(`${at}.otherwise`, effect.otherwise);
|
|
2598
|
+
});
|
|
2599
|
+
};
|
|
2600
|
+
for (const ability of rules.abilities ?? []) {
|
|
2601
|
+
const at = `abilities.${ability.id}`;
|
|
2602
|
+
known(`${at}.charge`, ability.charge, buffs, "buff");
|
|
2603
|
+
if (ability.charges && under(ability.charges.count, 1)) odd(`${at}.charges.count`, ability.id, `${at}.charges.count is under 1 at a level: it holds 1 there`);
|
|
2604
|
+
if (ability.charges && under(ability.charges.refill, 0, true)) odd(`${at}.charges.refill`, ability.id, `${at}.charges.refill is not above 0 at a level: a use comes back at once there`);
|
|
2605
|
+
effects(`${at}.effects`, ability.effects);
|
|
2606
|
+
effects(`${at}.passive`, ability.passive);
|
|
2607
|
+
}
|
|
2608
|
+
for (const buff of rules.buffs ?? []) {
|
|
2609
|
+
const at = `buffs.${buff.id}`;
|
|
2610
|
+
if (Array.isArray(buff.onApply)) effects(`${at}.onApply`, buff.onApply);
|
|
2611
|
+
effects(`${at}.onTick.effects`, buff.onTick?.effects);
|
|
2612
|
+
}
|
|
2613
|
+
for (const race of rules.races ?? []) {
|
|
2614
|
+
const start = race.start;
|
|
2615
|
+
if (!start || "spawn" in start) continue;
|
|
2616
|
+
const at = `races.${race.id}.start`;
|
|
2617
|
+
known(`${at}.hall`, start.hall, units, "unit type");
|
|
2618
|
+
known(`${at}.workers.type`, start.workers?.type, units, "unit type");
|
|
2619
|
+
known(`${at}.onMine`, start.onMine, units, "unit type");
|
|
2620
|
+
start.units?.forEach((each, index) => known(`${at}.units[${index}].type`, each.type, units, "unit type"));
|
|
2621
|
+
}
|
|
2622
|
+
for (const item of rules.items ?? []) {
|
|
2623
|
+
const at = `items.${item.id}`;
|
|
2624
|
+
known(`${at}.ability`, item.ability, abilities, "ability");
|
|
2625
|
+
known(`${at}.passive`, item.passive, abilities, "ability");
|
|
2626
|
+
all(`${at}.from`, item.from, items, "item");
|
|
2627
|
+
}
|
|
2628
|
+
for (const upgrade of rules.upgrades ?? []) {
|
|
2629
|
+
const at = `upgrades.${upgrade.id}`;
|
|
2630
|
+
upgrade.requires?.forEach((level, index) => all(`${at}.requires[${index}]`, level, requirable, "unit type or upgrade"));
|
|
2631
|
+
upgrade.effects.forEach((effect, index) => {
|
|
2632
|
+
if (Array.isArray(effect.units)) all(`${at}.effects[${index}].units`, effect.units, units, "unit type");
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
const needs = /* @__PURE__ */ new Map();
|
|
2636
|
+
for (const unit of rules.units) if (unit.requires?.length) needs.set(unit.id, unit.requires);
|
|
2637
|
+
for (const upgrade of rules.upgrades ?? []) if (upgrade.requires?.[0]?.length) needs.set(upgrade.id, upgrade.requires[0]);
|
|
2638
|
+
const reported = /* @__PURE__ */ new Set();
|
|
2639
|
+
for (const start of needs.keys()) {
|
|
2640
|
+
const loop = cycleFrom(start, needs);
|
|
2641
|
+
if (!loop) continue;
|
|
2642
|
+
const key = [...loop].sort().join(",");
|
|
2643
|
+
if (reported.has(key)) continue;
|
|
2644
|
+
reported.add(key);
|
|
2645
|
+
problems.push({
|
|
2646
|
+
level: "error",
|
|
2647
|
+
where: `requires`,
|
|
2648
|
+
id: start,
|
|
2649
|
+
message: `What is required waits on itself: ${[...loop, loop[0]].join(" → ")}. None of them can be had`
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
const made = /* @__PURE__ */ new Set();
|
|
2653
|
+
for (const unit of rules.units) {
|
|
2654
|
+
for (const id of [
|
|
2655
|
+
...unit.trains ?? [],
|
|
2656
|
+
...unit.builds ?? [],
|
|
2657
|
+
...unit.hires ?? [],
|
|
2658
|
+
...unit.researches ?? []
|
|
2659
|
+
]) made.add(id);
|
|
2660
|
+
for (const id of upgradesOf(unit)) made.add(id);
|
|
2661
|
+
}
|
|
2662
|
+
for (const unit of rules.units) {
|
|
2663
|
+
if (!unit.cost || made.has(unit.id)) continue;
|
|
2664
|
+
problems.push({
|
|
2665
|
+
level: "warning",
|
|
2666
|
+
where: `units.${unit.id}`,
|
|
2667
|
+
id: unit.id,
|
|
2668
|
+
message: `units.${unit.id} has a cost, and nothing trains, builds or hires it`
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
for (const upgrade of rules.upgrades ?? []) {
|
|
2672
|
+
if (made.has(upgrade.id)) continue;
|
|
2673
|
+
problems.push({
|
|
2674
|
+
level: "warning",
|
|
2675
|
+
where: `upgrades.${upgrade.id}`,
|
|
2676
|
+
id: upgrade.id,
|
|
2677
|
+
message: `upgrades.${upgrade.id} is researched nowhere (UnitTypeDef.researches)`
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
const had = /* @__PURE__ */ new Set();
|
|
2681
|
+
for (const unit of rules.units) for (const id of [
|
|
2682
|
+
...unit.abilities ?? [],
|
|
2683
|
+
...unit.hero?.learns ?? [],
|
|
2684
|
+
...unit.hero?.spells ?? []
|
|
2685
|
+
]) had.add(id);
|
|
2686
|
+
for (const item of rules.items ?? []) for (const id of [item.ability, item.passive]) if (id) had.add(id);
|
|
2687
|
+
const named = (ability) => {
|
|
2688
|
+
const walk = (list) => list?.map(anyEffect).forEach((effect) => (effect.ability && had.add(effect.ability), walk(effect.otherwise)));
|
|
2689
|
+
walk(ability.effects);
|
|
2690
|
+
walk(ability.passive);
|
|
2691
|
+
};
|
|
2692
|
+
for (const ability of rules.abilities ?? []) named(ability);
|
|
2693
|
+
for (const ability of rules.abilities ?? []) {
|
|
2694
|
+
if (had.has(ability.id)) continue;
|
|
2695
|
+
problems.push({
|
|
2696
|
+
level: "warning",
|
|
2697
|
+
where: `abilities.${ability.id}`,
|
|
2698
|
+
id: ability.id,
|
|
2699
|
+
message: `abilities.${ability.id} is on no unit, hero or item`
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
return problems.map((problem) => {
|
|
2703
|
+
const at = locate(rules, problem.where);
|
|
2704
|
+
return at ? {
|
|
2705
|
+
...problem,
|
|
2706
|
+
at
|
|
2707
|
+
} : problem;
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* The definition and field a place in the rules is (`abilities.holy-light.effects[0].buff`): the kind, the longest id
|
|
2712
|
+
* of it the place begins with (an id may have dots), and the rest as a path of the Data panel's (`effects.0.buff`)
|
|
2713
|
+
*/
|
|
2714
|
+
function locate(rules, where) {
|
|
2715
|
+
const dot = where.indexOf(".");
|
|
2716
|
+
const kind = where.slice(0, dot);
|
|
2717
|
+
if (dot < 0 || !DATA_KINDS.includes(kind)) return void 0;
|
|
2718
|
+
const rest = where.slice(dot + 1);
|
|
2719
|
+
const id = (rules[kind] ?? []).map((def) => def.id).filter((each) => rest === each || rest.startsWith(`${each}.`)).sort((a, b) => b.length - a.length)[0];
|
|
2720
|
+
if (id === void 0) return void 0;
|
|
2721
|
+
return {
|
|
2722
|
+
kind,
|
|
2723
|
+
id,
|
|
2724
|
+
path: rest.slice(id.length + 1).replace(/\[(\d+)\]/g, ".$1")
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
/** A loop of requirements through `start`, in order, or none */
|
|
2728
|
+
function cycleFrom(start, needs) {
|
|
2729
|
+
const path = [];
|
|
2730
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2731
|
+
const visit = (id) => {
|
|
2732
|
+
if (id === start && path.length) return [...path];
|
|
2733
|
+
if (seen.has(id)) return void 0;
|
|
2734
|
+
seen.add(id);
|
|
2735
|
+
path.push(id);
|
|
2736
|
+
for (const next of needs.get(id) ?? []) {
|
|
2737
|
+
const found = visit(next);
|
|
2738
|
+
if (found) return found;
|
|
2739
|
+
}
|
|
2740
|
+
path.pop();
|
|
2741
|
+
};
|
|
2742
|
+
return visit(start);
|
|
2743
|
+
}
|
|
2744
|
+
//#endregion
|
|
2745
|
+
//#region src/attributes.ts
|
|
2746
|
+
/**
|
|
2747
|
+
* The attributes pack's id
|
|
2748
|
+
*
|
|
2749
|
+
* @beta
|
|
2750
|
+
*/
|
|
2751
|
+
var ATTRIBUTES = "core.attributes";
|
|
2752
|
+
/**
|
|
2753
|
+
* The three attributes, in the order a hero's panel shows them
|
|
2754
|
+
*
|
|
2755
|
+
* @beta
|
|
2756
|
+
*/
|
|
2757
|
+
var ATTRIBUTE_KEYS = [
|
|
2758
|
+
"strength",
|
|
2759
|
+
"agility",
|
|
2760
|
+
"intelligence"
|
|
2761
|
+
];
|
|
2762
|
+
/**
|
|
2763
|
+
* What a point of each attribute gives, as classic RTS games have it: strength 25 health and 0.05 health a second, agility 0.3
|
|
2764
|
+
* armor and 2% attack speed, intelligence 15 mana and 0.05 mana a second
|
|
2765
|
+
*
|
|
2766
|
+
* @beta
|
|
2767
|
+
*/
|
|
2768
|
+
var CLASSIC_ATTRIBUTES = {
|
|
2769
|
+
strength: { stats: {
|
|
2770
|
+
health: 25,
|
|
2771
|
+
healthRegen: .05
|
|
2772
|
+
} },
|
|
2773
|
+
agility: {
|
|
2774
|
+
stats: { armor: .3 },
|
|
2775
|
+
percent: { attackSpeed: .02 }
|
|
2776
|
+
},
|
|
2777
|
+
intelligence: { stats: {
|
|
2778
|
+
mana: 15,
|
|
2779
|
+
manaRegen: .05
|
|
2780
|
+
} }
|
|
2781
|
+
};
|
|
2782
|
+
var attributeStats = [
|
|
2783
|
+
{
|
|
2784
|
+
id: "strength",
|
|
2785
|
+
name: "Strength",
|
|
2786
|
+
description: "Each point adds to health and its regeneration",
|
|
2787
|
+
min: 0
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
id: "agility",
|
|
2791
|
+
name: "Agility",
|
|
2792
|
+
description: "Each point adds to armor and attack speed",
|
|
2793
|
+
min: 0
|
|
2794
|
+
},
|
|
2795
|
+
{
|
|
2796
|
+
id: "intelligence",
|
|
2797
|
+
name: "Intelligence",
|
|
2798
|
+
description: "Each point adds to mana and its regeneration",
|
|
2799
|
+
min: 0
|
|
2800
|
+
}
|
|
2801
|
+
];
|
|
2802
|
+
/** Whether a unit's type starts with any attribute */
|
|
2803
|
+
function startsWithAttributes(entity) {
|
|
2804
|
+
const stats = entity.def.stats;
|
|
2805
|
+
return !!stats && !!(stats.strength || stats.agility || stats.intelligence);
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* What a unit has of each attribute, everything counted (its type, its level, items, buffs, research): its stats'
|
|
2809
|
+
* `strength`, `agility` and `intelligence`
|
|
2810
|
+
*
|
|
2811
|
+
* @beta
|
|
2812
|
+
*/
|
|
2813
|
+
function attributesOf(world, entity) {
|
|
2814
|
+
const stats = statsOf(world, entity);
|
|
2815
|
+
return {
|
|
2816
|
+
strength: stats.strength ?? 0,
|
|
2817
|
+
agility: stats.agility ?? 0,
|
|
2818
|
+
intelligence: stats.intelligence ?? 0
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* A unit's primary attribute, as the attributes pack of its game says it (`AttributesOptions.primaryOf`, else its type's
|
|
2823
|
+
* `hero.primary`); none in a game without the pack
|
|
2824
|
+
*
|
|
2825
|
+
* @beta
|
|
2826
|
+
*/
|
|
2827
|
+
function primaryAttribute(world, entity) {
|
|
2828
|
+
const pack = world.registry.assembly?.game.packs?.find((each) => each.id === ATTRIBUTES);
|
|
2829
|
+
if (!pack) return void 0;
|
|
2830
|
+
const options = pack.options;
|
|
2831
|
+
return options.primaryOf ? options.primaryOf(world, entity) : entity.def.hero?.primary;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Hero attributes, as in classic RTS games: strength, agility and intelligence are stats (`ATTRIBUTE_KEYS`), and every point
|
|
2835
|
+
* of them adds to the others (`AttributesOptions.perPoint`, `CLASSIC_ATTRIBUTES` when left out), the primary one's
|
|
2836
|
+
* to the attack too (`hero.primary`, `primaryOf`). What a unit has of them is what it has of any stat: its type's
|
|
2837
|
+
* `stats`, its hero's `growth` by level, items' `bonus`, buffs, research and the `stats` hook, all of it added up
|
|
2838
|
+
* before the points are turned into the rest. A game without it has no attributes
|
|
2839
|
+
*
|
|
2840
|
+
* @beta
|
|
2841
|
+
*/
|
|
2842
|
+
function withAttributes(options = {}) {
|
|
2843
|
+
const perPoint = {
|
|
2844
|
+
...CLASSIC_ATTRIBUTES,
|
|
2845
|
+
...options.perPoint
|
|
2846
|
+
};
|
|
2847
|
+
const primaryBonus = options.primary ?? { stats: { attackDamage: 1 } };
|
|
2848
|
+
const primaryOf = options.primaryOf ?? ((_world, entity) => entity.def.hero?.primary);
|
|
2849
|
+
const points = {
|
|
2850
|
+
id: "attributes",
|
|
2851
|
+
stamp: (entity) => startsWithAttributes(entity) ? heroOf(entity)?.level ?? 1 : 0,
|
|
2852
|
+
add() {},
|
|
2853
|
+
derive(world, entity, sum) {
|
|
2854
|
+
const strength = sum.get("strength");
|
|
2855
|
+
const agility = sum.get("agility");
|
|
2856
|
+
const intelligence = sum.get("intelligence");
|
|
2857
|
+
if (strength) sum.add(perPoint.strength, strength);
|
|
2858
|
+
if (agility) sum.add(perPoint.agility, agility);
|
|
2859
|
+
if (intelligence) sum.add(perPoint.intelligence, intelligence);
|
|
2860
|
+
const primary = primaryOf(world, entity);
|
|
2861
|
+
const of = primary === "strength" ? strength : primary === "agility" ? agility : primary === "intelligence" ? intelligence : 0;
|
|
2862
|
+
if (of) sum.add(primaryBonus, of);
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
const made = defineHook({
|
|
2866
|
+
hook: "made",
|
|
2867
|
+
id: "attributes",
|
|
2868
|
+
run(world, entity) {
|
|
2869
|
+
if (startsWithAttributes(entity)) refreshVitals(world, entity);
|
|
2870
|
+
}
|
|
2871
|
+
});
|
|
2872
|
+
return {
|
|
2873
|
+
id: ATTRIBUTES,
|
|
2874
|
+
options,
|
|
2875
|
+
stats: attributeStats,
|
|
2876
|
+
statParts: [points],
|
|
2877
|
+
hooks: [made]
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
//#endregion
|
|
2881
|
+
//#region src/manaBurn.ts
|
|
2882
|
+
/**
|
|
2883
|
+
* The mana burn pack's id
|
|
2884
|
+
*
|
|
2885
|
+
* @beta
|
|
2886
|
+
*/
|
|
2887
|
+
var MANA_BURN = "core.manaBurn";
|
|
2888
|
+
/**
|
|
2889
|
+
* Takes up to `amount` of a unit's mana and hurts it by as much as it took (times `ratio`), from `source`: what the
|
|
2890
|
+
* `manaBurn` effect does, for a game's own rules. Gives back the mana it took: none of a unit that has none, and no
|
|
2891
|
+
* damage then
|
|
2892
|
+
*
|
|
2893
|
+
* @beta
|
|
2894
|
+
*/
|
|
2895
|
+
function burnMana(world, target, amount, source, options = {}) {
|
|
2896
|
+
const burnt = Math.min(Math.max(amount, 0), target.mana);
|
|
2897
|
+
if (burnt <= 0) return 0;
|
|
2898
|
+
target.mana -= burnt;
|
|
2899
|
+
world.events.emit(manaChanged, {
|
|
2900
|
+
entity: target,
|
|
2901
|
+
amount: -burnt,
|
|
2902
|
+
ability: options.ability,
|
|
2903
|
+
source
|
|
2904
|
+
});
|
|
2905
|
+
const damage = burnt * (options.ratio ?? 1);
|
|
2906
|
+
if (damage > 0) world.damage(target, damage, source, {
|
|
2907
|
+
damageType: options.damageType ?? "magic",
|
|
2908
|
+
ability: options.ability
|
|
2909
|
+
});
|
|
2910
|
+
return burnt;
|
|
2911
|
+
}
|
|
2912
|
+
var manaBurnEffect = defineEffect("manaBurn", (context, effect) => {
|
|
2913
|
+
const { world, caster, level } = context;
|
|
2914
|
+
const amount = scaled(world, caster, at(effect.amount, level), effect.scaling, level);
|
|
2915
|
+
for (const entity of effectTargets(context, effect, "enemies")) burnMana(world, entity, amount, caster, {
|
|
2916
|
+
ratio: effect.ratio,
|
|
2917
|
+
damageType: effect.damageType,
|
|
2918
|
+
ability: context.ability.id
|
|
2919
|
+
});
|
|
2920
|
+
}, { fields: [
|
|
2921
|
+
"amount",
|
|
2922
|
+
"scaling",
|
|
2923
|
+
"damageType",
|
|
2924
|
+
{
|
|
2925
|
+
path: "ratio",
|
|
2926
|
+
kind: "number",
|
|
2927
|
+
min: 0,
|
|
2928
|
+
hint: "Times the mana taken it hurts by: 1 when left out"
|
|
2929
|
+
}
|
|
2930
|
+
] });
|
|
2931
|
+
/**
|
|
2932
|
+
* Mana burn, as in classic RTS games: the `manaBurn` effect (`EffectTypes.manaBurn`) takes `amount` of what it reaches of its
|
|
2933
|
+
* mana and hurts it by as much (times `ratio`). The demon hunter's spell is an ability with it; Feedback is a passive
|
|
2934
|
+
* whose `onHit` has it. `burnMana` does it in code
|
|
2935
|
+
*
|
|
2936
|
+
* @beta
|
|
2937
|
+
*/
|
|
2938
|
+
function withManaBurn() {
|
|
2939
|
+
return {
|
|
2940
|
+
id: MANA_BURN,
|
|
2941
|
+
effects: [manaBurnEffect]
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
//#endregion
|
|
2945
|
+
//#region src/destructibles.ts
|
|
2946
|
+
/**
|
|
2947
|
+
* The destructibles pack's id
|
|
2948
|
+
*
|
|
2949
|
+
* @beta
|
|
2950
|
+
*/
|
|
2951
|
+
var DESTRUCTIBLES = "core.destructibles";
|
|
2952
|
+
/**
|
|
2953
|
+
* Destructibles, as in classic RTS games: what is `destructible` (`UnitTypeDef.destructible`, a building of the neutral player
|
|
2954
|
+
* placed on the map: rocks, a barricade, a gate to break down) stands in the way until it is brought down, and any unit
|
|
2955
|
+
* whose attack reaches it attacks it when told to (the attack command), never of its own accord, as trees are felled
|
|
2956
|
+
* by what fells them. A gate that opens and shuts is the map's (`setGate`); one that is broken down is a destructible
|
|
2957
|
+
*
|
|
2958
|
+
* @beta
|
|
2959
|
+
*/
|
|
2960
|
+
function withDestructibles(options = {}) {
|
|
2961
|
+
const destructible = options.destructible ?? ((_world, entity) => !!entity.def.destructible);
|
|
2962
|
+
return {
|
|
2963
|
+
id: DESTRUCTIBLES,
|
|
2964
|
+
options,
|
|
2965
|
+
hooks: [defineHook({
|
|
2966
|
+
hook: "canAttack",
|
|
2967
|
+
id: "destructibles",
|
|
2968
|
+
run(world, event) {
|
|
2969
|
+
if (event.allowed || !event.ordered) return;
|
|
2970
|
+
if (destructible(world, event.target) && attackReaches(world, event.attacker, event.target)) event.allowed = true;
|
|
2971
|
+
}
|
|
2972
|
+
})]
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2975
|
+
//#endregion
|
|
2976
|
+
//#region src/portals.ts
|
|
2977
|
+
/**
|
|
2978
|
+
* The portals pack's id
|
|
2979
|
+
*
|
|
2980
|
+
* @beta
|
|
2981
|
+
*/
|
|
2982
|
+
var PORTALS = "core.portals";
|
|
2983
|
+
/**
|
|
2984
|
+
* Where a waygate leads: a point of the map by its name (as the editor's field keeps it), or a place. A waygate without
|
|
2985
|
+
* it, or with `null`, is shut
|
|
2986
|
+
*
|
|
2987
|
+
* @beta
|
|
2988
|
+
*/
|
|
2989
|
+
var waygate = defineComponent("core.waygate", { field: {
|
|
2990
|
+
name: "Leads to",
|
|
2991
|
+
hint: "The point of the map a unit that walks in comes out at",
|
|
2992
|
+
kind: "point",
|
|
2993
|
+
for: (def) => !!def.waygate
|
|
2994
|
+
} });
|
|
2995
|
+
/**
|
|
2996
|
+
* A unit went through a waygate, or a town portal: told to everyone who sees either end, for a sound and a flash
|
|
2997
|
+
*
|
|
2998
|
+
* @beta
|
|
2999
|
+
*/
|
|
3000
|
+
var portalUsed = defineEvent("core.portalUsed", { replicate: "everyone" });
|
|
3001
|
+
/** How near a waygate's edge a unit is in it */
|
|
3002
|
+
var REACH$1 = .6;
|
|
3003
|
+
/**
|
|
3004
|
+
* Where a waygate leads, or nothing for one that is shut or leads to a point the map does not have
|
|
3005
|
+
*
|
|
3006
|
+
* @beta
|
|
3007
|
+
*/
|
|
3008
|
+
function waygateTarget(world, gate) {
|
|
3009
|
+
const to = gate.get(waygate);
|
|
3010
|
+
if (!to || !gate.alive) return void 0;
|
|
3011
|
+
if (typeof to !== "string") return to;
|
|
3012
|
+
return world.map.points.some((point) => point.name === to) ? world.map.point(to) : void 0;
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Says where a waygate leads (a point of the map by its name, or a place), or shuts it (`null`): what the editor's
|
|
3016
|
+
* "Leads to" field says of a placed one, in code. Any building can be made a waygate so
|
|
3017
|
+
*
|
|
3018
|
+
* @beta
|
|
3019
|
+
*/
|
|
3020
|
+
function setWaygate(gate, to) {
|
|
3021
|
+
gate.set(waygate, typeof to === "object" && to ? {
|
|
3022
|
+
x: to.x,
|
|
3023
|
+
z: to.z
|
|
3024
|
+
} : to);
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Puts a unit somewhere at once, on the nearest ground it can stand on: what a waygate and a town portal do, for a game's
|
|
3028
|
+
* own rules. Told to everyone (`portalUsed`)
|
|
3029
|
+
*
|
|
3030
|
+
* @beta
|
|
3031
|
+
*/
|
|
3032
|
+
function portal(world, entity, to, gate) {
|
|
3033
|
+
const from = {
|
|
3034
|
+
x: entity.pos.x,
|
|
3035
|
+
z: entity.pos.z
|
|
3036
|
+
};
|
|
3037
|
+
const spot = (entity.flying ? world.air : world.nav).closestPoint(to, 6) ?? to;
|
|
3038
|
+
halt(world, entity);
|
|
3039
|
+
world.place(entity, spot);
|
|
3040
|
+
entity.prevPos.x = spot.x;
|
|
3041
|
+
entity.prevPos.z = spot.z;
|
|
3042
|
+
world.events.emit(portalUsed, {
|
|
3043
|
+
entity: entity.id,
|
|
3044
|
+
from,
|
|
3045
|
+
to: {
|
|
3046
|
+
x: spot.x,
|
|
3047
|
+
z: spot.z
|
|
3048
|
+
},
|
|
3049
|
+
gate: gate?.id
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
/** Going through a waygate: walking into it, then out where it leads. Failed when it is shut, or there is no way to it */
|
|
3053
|
+
var enterOrder = {
|
|
3054
|
+
type: "enterWaygate",
|
|
3055
|
+
accepts(world, entity, init) {
|
|
3056
|
+
const gate = world.get(init.target);
|
|
3057
|
+
return !!entity.get(moving) && !!gate && !!waygateTarget(world, gate);
|
|
3058
|
+
},
|
|
3059
|
+
update(world, entity, order) {
|
|
3060
|
+
const gate = world.get(order.init.target);
|
|
3061
|
+
const to = gate && waygateTarget(world, gate);
|
|
3062
|
+
if (!gate || !to) return "failed";
|
|
3063
|
+
if (distanceToEdge(entity.pos, gate) - entity.def.radius <= REACH$1) {
|
|
3064
|
+
portal(world, entity, to, gate);
|
|
3065
|
+
return "done";
|
|
3066
|
+
}
|
|
3067
|
+
const m = entity.get(moving);
|
|
3068
|
+
if (m.status === "moving") return "running";
|
|
3069
|
+
if (m.status === "failed" && order.state.tried === world.tick - 1) return "failed";
|
|
3070
|
+
order.state.tried = world.tick;
|
|
3071
|
+
moveToEntity(world, entity, gate, REACH$1 * .5);
|
|
3072
|
+
return "running";
|
|
3073
|
+
}
|
|
3074
|
+
};
|
|
3075
|
+
/** Offsets around a spot, nearest first: where those a town portal brings stand */
|
|
3076
|
+
var AROUND = (() => {
|
|
3077
|
+
const all = [];
|
|
3078
|
+
for (let dz = -3; dz <= 3; dz++) for (let dx = -3; dx <= 3; dx++) all.push({
|
|
3079
|
+
x: dx * 1.2,
|
|
3080
|
+
z: dz * 1.2
|
|
3081
|
+
});
|
|
3082
|
+
return all.sort((a, b) => a.x * a.x + a.z * a.z - (b.x * b.x + b.z * b.z));
|
|
3083
|
+
})();
|
|
3084
|
+
/**
|
|
3085
|
+
* Takes a unit and as many as `count` of its owner's units within `radius` of it, nearest first, to its side's home
|
|
3086
|
+
* nearest `near` (`homeOf`), spread around it: what the `townPortal` effect does, in code. Gives back who went, none
|
|
3087
|
+
* when the side has no home
|
|
3088
|
+
*
|
|
3089
|
+
* @beta
|
|
3090
|
+
*/
|
|
3091
|
+
function townPortal(world, caster, near, radius = 7, count = 12) {
|
|
3092
|
+
const home = homeOf(world, caster.owner, near);
|
|
3093
|
+
if (!home) return [];
|
|
3094
|
+
const going = [caster, ...world.spatial.query(caster.pos.x, caster.pos.z, radius).filter((entity) => entity !== caster && entity.alive && !entity.hidden && entity.owner === caster.owner && !!entity.get(moving) && distanceToEdge(caster.pos, entity) <= radius).map((entity) => ({
|
|
3095
|
+
entity,
|
|
3096
|
+
d: distSq(entity.pos, caster.pos)
|
|
3097
|
+
})).sort((a, b) => a.d - b.d || a.entity.id - b.entity.id).slice(0, Math.max(0, count)).map(({ entity }) => entity)];
|
|
3098
|
+
going.forEach((entity, i) => {
|
|
3099
|
+
const offset = AROUND[i % AROUND.length];
|
|
3100
|
+
portal(world, entity, {
|
|
3101
|
+
x: home.x + offset.x,
|
|
3102
|
+
z: home.z + offset.z
|
|
3103
|
+
});
|
|
3104
|
+
});
|
|
3105
|
+
return going;
|
|
3106
|
+
}
|
|
3107
|
+
var townPortalEffect = defineEffect("townPortal", (context, effect) => {
|
|
3108
|
+
const { world, caster, point, level } = context;
|
|
3109
|
+
townPortal(world, caster, point, at(effect.radius, level, 7), Math.round(at(effect.count, level, 12)));
|
|
3110
|
+
}, { fields: ["count"] });
|
|
3111
|
+
/**
|
|
3112
|
+
* Portals, as in classic RTS games: waygates (`UnitTypeDef.waygate`), which a unit told to (a right-click, as any building is
|
|
3113
|
+
* walked up to) walks into and comes out of where the gate leads (the "Leads to" field of a placed one, a point of the
|
|
3114
|
+
* map; `setWaygate` in code), and the `townPortal` effect (`EffectTypes.townPortal`), which takes the caster and its
|
|
3115
|
+
* units around it to its side's home nearest where it was aimed (`homeOf`: the map's `home.<team>` point, or the
|
|
3116
|
+
* nearest of its buildings). `portal` and `townPortal` do them in code
|
|
3117
|
+
*
|
|
3118
|
+
* @beta
|
|
3119
|
+
*/
|
|
3120
|
+
function withPortals() {
|
|
3121
|
+
const intoGate = defineHook({
|
|
3122
|
+
hook: "beforeOrder",
|
|
3123
|
+
id: "portals",
|
|
3124
|
+
run(world, event) {
|
|
3125
|
+
const { order, entity } = event;
|
|
3126
|
+
if (order.type !== "move" || order.init.target === void 0) return;
|
|
3127
|
+
const gate = world.get(order.init.target);
|
|
3128
|
+
if (!gate?.def.waygate || !waygateTarget(world, gate) || !entity.get(moving)) return;
|
|
3129
|
+
event.rejected = true;
|
|
3130
|
+
giveOrder(world, entity, createOrder(world, {
|
|
3131
|
+
type: "enterWaygate",
|
|
3132
|
+
target: gate.id
|
|
3133
|
+
}, order.groupId), event.queued);
|
|
3134
|
+
}
|
|
3135
|
+
});
|
|
3136
|
+
return {
|
|
3137
|
+
id: PORTALS,
|
|
3138
|
+
orders: [enterOrder],
|
|
3139
|
+
components: [waygate],
|
|
3140
|
+
events: [portalUsed],
|
|
3141
|
+
effects: [townPortalEffect],
|
|
3142
|
+
hooks: [intoGate]
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
//#endregion
|
|
3146
|
+
//#region src/runes.ts
|
|
3147
|
+
/**
|
|
3148
|
+
* The runes pack's id
|
|
3149
|
+
*
|
|
3150
|
+
* @beta
|
|
3151
|
+
*/
|
|
3152
|
+
var RUNES = "core.runes";
|
|
3153
|
+
/**
|
|
3154
|
+
* Uses an item on the ground as a rune is used: whoever picked it casts its ability (where it stands, no mana), and it
|
|
3155
|
+
* is gone from the ground, told as an item picked (`itemPicked`). What the runes pack does as a unit walks over one;
|
|
3156
|
+
* false for an item not on the ground
|
|
3157
|
+
*
|
|
3158
|
+
* @beta
|
|
3159
|
+
*/
|
|
3160
|
+
function usePowerUp(world, entity, groundId) {
|
|
3161
|
+
const index = world.items.findIndex((item) => item.id === groundId);
|
|
3162
|
+
if (index < 0) return false;
|
|
3163
|
+
const [item] = world.items.splice(index, 1);
|
|
3164
|
+
const instance = createItem(world, item.item, item.charges);
|
|
3165
|
+
instance.id = item.id;
|
|
3166
|
+
world.events.emit(itemPicked, {
|
|
3167
|
+
entity,
|
|
3168
|
+
item: instance
|
|
3169
|
+
});
|
|
3170
|
+
const ability = world.registry.item(item.item).ability;
|
|
3171
|
+
if (ability) fire(world, entity, world.registry.ability(ability), void 0, instance);
|
|
3172
|
+
return true;
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Runes, as in classic RTS games: an item that is a power-up (`ItemDef.powerUp`, `RunesOptions.powerUp` in code) is used as
|
|
3176
|
+
* it is picked up, by a unit that walks over it (`RunesOptions.takes`) or is told to pick it up, and never carried:
|
|
3177
|
+
* whoever picked it casts its ability where it stands. A rune of healing is an item whose ability heals around; a tome
|
|
3178
|
+
* one whose ability has the tomes pack's `tome` effect. `usePowerUp` does it in code
|
|
3179
|
+
*
|
|
3180
|
+
* @beta
|
|
3181
|
+
*/
|
|
3182
|
+
function withRunes(options = {}) {
|
|
3183
|
+
const powerUp = options.powerUp ?? ((item) => !!item.powerUp);
|
|
3184
|
+
const takes = options.takes ?? ((world, entity) => entity.isUnit && !entity.hidden && !world.player(entity.owner).neutral && !entity.def.invulnerable);
|
|
3185
|
+
const walkedOver = {
|
|
3186
|
+
name: "runes",
|
|
3187
|
+
phase: "cleanup",
|
|
3188
|
+
every: Math.max(1, Math.round(options.every ?? 3)),
|
|
3189
|
+
update(world) {
|
|
3190
|
+
if (!world.items.length) return;
|
|
3191
|
+
for (const item of [...world.items]) {
|
|
3192
|
+
if (!powerUp(world.registry.item(item.item))) continue;
|
|
3193
|
+
for (const entity of world.spatial.query(item.x, item.z, REACH)) {
|
|
3194
|
+
if (!entity.alive || distanceToEdge(item, entity) > 1.2 || !takes(world, entity, item)) continue;
|
|
3195
|
+
usePowerUp(world, entity, item.id);
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
const ordered = defineHook({
|
|
3202
|
+
hook: "beforePickUp",
|
|
3203
|
+
id: "runes",
|
|
3204
|
+
run(world, event) {
|
|
3205
|
+
if (!powerUp(world.registry.item(event.item))) return;
|
|
3206
|
+
event.cancelled = true;
|
|
3207
|
+
usePowerUp(world, event.entity, event.ground);
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
3210
|
+
return {
|
|
3211
|
+
id: RUNES,
|
|
3212
|
+
options,
|
|
3213
|
+
systems: [walkedOver],
|
|
3214
|
+
hooks: [ordered]
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
//#endregion
|
|
3218
|
+
//#region src/tomes.ts
|
|
3219
|
+
/**
|
|
3220
|
+
* The tomes pack's id
|
|
3221
|
+
*
|
|
3222
|
+
* @beta
|
|
3223
|
+
*/
|
|
3224
|
+
var TOMES = "core.tomes";
|
|
3225
|
+
/**
|
|
3226
|
+
* What a unit gained for good from tomes or the game's own rules (`gainStats`): saved with it
|
|
3227
|
+
*
|
|
3228
|
+
* @beta
|
|
3229
|
+
*/
|
|
3230
|
+
var gainedStats = defineComponent("core.gainedStats");
|
|
3231
|
+
/**
|
|
3232
|
+
* Gives a unit stats for good, as a tome does: added to what it gained before, kept with it in a save, its health and
|
|
3233
|
+
* mana raised with them. For the tomes pack's `tome` effect and a game's own rules (a shrine that makes a unit
|
|
3234
|
+
* stronger); it works in a game with the tomes pack (`withTomes`), which adds what was gained to the stats
|
|
3235
|
+
*
|
|
3236
|
+
* @beta
|
|
3237
|
+
*/
|
|
3238
|
+
function gainStats(world, entity, change) {
|
|
3239
|
+
const had = entity.get(gainedStats);
|
|
3240
|
+
const next = {
|
|
3241
|
+
stats: { ...had?.stats },
|
|
3242
|
+
percent: { ...had?.percent },
|
|
3243
|
+
times: (had?.times ?? 0) + 1
|
|
3244
|
+
};
|
|
3245
|
+
for (const key in change.stats) next.stats[key] = (next.stats[key] ?? 0) + change.stats[key];
|
|
3246
|
+
for (const key in change.percent) next.percent[key] = (next.percent[key] ?? 0) + change.percent[key];
|
|
3247
|
+
entity.set(gainedStats, next);
|
|
3248
|
+
invalidateStats(world, entity);
|
|
3249
|
+
refreshVitals(world, entity);
|
|
3250
|
+
}
|
|
3251
|
+
/** A tome read: what it gives for good, the experience and the levels it gives a hero */
|
|
3252
|
+
var tomeEffect = defineEffect("tome", (context, effect) => {
|
|
3253
|
+
const { world } = context;
|
|
3254
|
+
for (const entity of effectTargets(context, effect, "allies")) {
|
|
3255
|
+
if (effect.gain) gainStats(world, entity, effect.gain);
|
|
3256
|
+
if (effect.xp) grantXp(world, entity, effect.xp);
|
|
3257
|
+
for (let i = 0; i < (effect.levels ?? 0); i++) {
|
|
3258
|
+
const hero = heroOf(entity);
|
|
3259
|
+
if (!hero || hero.level >= maxLevelOf(world, entity)) break;
|
|
3260
|
+
grantXp(world, entity, world.rules.heroes.xpForLevel(hero.level + 1) - hero.xp);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
}, { fields: [{
|
|
3264
|
+
path: "xp",
|
|
3265
|
+
kind: "integer",
|
|
3266
|
+
min: 0,
|
|
3267
|
+
hint: "Experience a hero gains"
|
|
3268
|
+
}, {
|
|
3269
|
+
path: "levels",
|
|
3270
|
+
kind: "integer",
|
|
3271
|
+
min: 0,
|
|
3272
|
+
hint: "Levels a hero gains"
|
|
3273
|
+
}] });
|
|
3274
|
+
/** What was gained, added to the stats: worked out again each time something is gained */
|
|
3275
|
+
var gained = {
|
|
3276
|
+
id: "tomes",
|
|
3277
|
+
stamp: (entity) => entity.get(gainedStats)?.times ?? 0,
|
|
3278
|
+
add(_world, entity, sum) {
|
|
3279
|
+
const had = entity.get(gainedStats);
|
|
3280
|
+
if (had) sum.add(had);
|
|
3281
|
+
}
|
|
3282
|
+
};
|
|
3283
|
+
/**
|
|
3284
|
+
* Tomes, as in classic RTS games: the `tome` effect (`EffectTypes.tome`) gives what it reaches stats for good (`gain`: a
|
|
3285
|
+
* point of strength with the attributes pack), a hero experience (`xp`) and levels (`levels`). A tome is an item whose
|
|
3286
|
+
* ability has the effect, with a charge, used from the inventory, or as it is picked up with the runes pack
|
|
3287
|
+
* (`withRunes`). What a unit gained is kept with it and in a save (`gainedStats`), and a game gives it from its own
|
|
3288
|
+
* rules with `gainStats`
|
|
3289
|
+
*
|
|
3290
|
+
* @beta
|
|
3291
|
+
*/
|
|
3292
|
+
function withTomes() {
|
|
3293
|
+
return {
|
|
3294
|
+
id: TOMES,
|
|
3295
|
+
components: [gainedStats],
|
|
3296
|
+
effects: [tomeEffect],
|
|
3297
|
+
statParts: [gained]
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
//#endregion
|
|
3301
|
+
//#region src/walls.ts
|
|
3302
|
+
/**
|
|
3303
|
+
* A piece of wall took another shape, or turned: a piece came or went beside it
|
|
3304
|
+
*
|
|
3305
|
+
* @beta
|
|
3306
|
+
*/
|
|
3307
|
+
var wallJoined = defineEvent("core.wallJoined", { replicate: "everyone" });
|
|
3308
|
+
/** The four sides, as the cell beside and the facing toward it */
|
|
3309
|
+
var SIDES = [
|
|
3310
|
+
{
|
|
3311
|
+
dx: 1,
|
|
3312
|
+
dz: 0,
|
|
3313
|
+
facing: 0
|
|
3314
|
+
},
|
|
3315
|
+
{
|
|
3316
|
+
dx: 0,
|
|
3317
|
+
dz: 1,
|
|
3318
|
+
facing: Math.PI / 2
|
|
3319
|
+
},
|
|
3320
|
+
{
|
|
3321
|
+
dx: -1,
|
|
3322
|
+
dz: 0,
|
|
3323
|
+
facing: Math.PI
|
|
3324
|
+
},
|
|
3325
|
+
{
|
|
3326
|
+
dx: 0,
|
|
3327
|
+
dz: -1,
|
|
3328
|
+
facing: -Math.PI / 2
|
|
3329
|
+
}
|
|
3330
|
+
];
|
|
3331
|
+
var near = [];
|
|
3332
|
+
/** The live piece of a family standing on the cell of this point: by its footprint, a gate wider than a cell too */
|
|
3333
|
+
function pieceAt(world, family, x, z) {
|
|
3334
|
+
const cx = Math.floor(x) + .5;
|
|
3335
|
+
const cz = Math.floor(z) + .5;
|
|
3336
|
+
for (const other of world.spatial.query(cx, cz, 2, near)) {
|
|
3337
|
+
if (!other.alive || other.def.wall?.family !== family) continue;
|
|
3338
|
+
const rect = other.rect;
|
|
3339
|
+
if (Math.abs(cx - rect.x) < rect.hw && Math.abs(cz - rect.z) < rect.hd) return other;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
/** The cell just past a piece's side: the next cell for a piece of one, past its footprint for a wider one (a gate) */
|
|
3343
|
+
var besideOf = (rect, dx, dz) => ({
|
|
3344
|
+
x: rect.x + dx * (rect.hw + .5),
|
|
3345
|
+
z: rect.z + dz * (rect.hd + .5)
|
|
3346
|
+
});
|
|
3347
|
+
/**
|
|
3348
|
+
* The shape of a piece by the sides it has a piece of its family on (east, north, west, south), and which way it faces:
|
|
3349
|
+
* an end toward its one neighbour, a straight piece along its line, a corner toward the first of its two sides, a tee
|
|
3350
|
+
* toward the side it has none on
|
|
3351
|
+
*
|
|
3352
|
+
* @internal
|
|
3353
|
+
*/
|
|
3354
|
+
function wallShapeOf(sides) {
|
|
3355
|
+
const count = sides.filter(Boolean).length;
|
|
3356
|
+
if (count === 0) return {
|
|
3357
|
+
shape: "single",
|
|
3358
|
+
facing: 0
|
|
3359
|
+
};
|
|
3360
|
+
if (count === 4) return {
|
|
3361
|
+
shape: "cross",
|
|
3362
|
+
facing: 0
|
|
3363
|
+
};
|
|
3364
|
+
if (count === 1) return {
|
|
3365
|
+
shape: "end",
|
|
3366
|
+
facing: SIDES[sides.indexOf(true)].facing
|
|
3367
|
+
};
|
|
3368
|
+
if (count === 3) return {
|
|
3369
|
+
shape: "tee",
|
|
3370
|
+
facing: SIDES[(sides.indexOf(false) + 2) % 4].facing
|
|
3371
|
+
};
|
|
3372
|
+
if (sides[0] === sides[2]) return {
|
|
3373
|
+
shape: "straight",
|
|
3374
|
+
facing: sides[0] ? 0 : Math.PI / 2
|
|
3375
|
+
};
|
|
3376
|
+
return {
|
|
3377
|
+
shape: "corner",
|
|
3378
|
+
facing: SIDES[sides[0] && sides[1] ? 0 : sides[1] && sides[2] ? 1 : sides[2] && sides[3] ? 2 : 3].facing
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
/** Works out a piece's shape from its neighbours again, and says so where it changed */
|
|
3382
|
+
function reshape(world, piece) {
|
|
3383
|
+
const family = piece.def.wall?.family;
|
|
3384
|
+
if (!family || !piece.alive) return;
|
|
3385
|
+
const rect = piece.rect;
|
|
3386
|
+
const { shape, facing } = wallShapeOf(SIDES.map(({ dx, dz }) => {
|
|
3387
|
+
const beside = besideOf(rect, dx, dz);
|
|
3388
|
+
return !!pieceAt(world, family, beside.x, beside.z);
|
|
3389
|
+
}));
|
|
3390
|
+
const look = piece.def.wall?.gate ? piece.look : shape;
|
|
3391
|
+
if (piece.look === look && piece.facing === facing) return;
|
|
3392
|
+
piece.look = look;
|
|
3393
|
+
piece.facing = facing;
|
|
3394
|
+
world.events.emit(wallJoined, {
|
|
3395
|
+
entity: piece.id,
|
|
3396
|
+
shape,
|
|
3397
|
+
facing
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
/** A piece and the pieces of its family beside it, shaped again: one came, or went */
|
|
3401
|
+
function reshapeAround(world, rect, family, self) {
|
|
3402
|
+
if (self) reshape(world, self);
|
|
3403
|
+
for (const { dx, dz } of SIDES) {
|
|
3404
|
+
const beside = besideOf(rect, dx, dz);
|
|
3405
|
+
const other = pieceAt(world, family, beside.x, beside.z);
|
|
3406
|
+
if (other && other !== self) reshape(world, other);
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* The cells of a wall along a drag, as the genre lays one: along the longer of its two directions first, then the
|
|
3411
|
+
* other, a cell's middle each (`x + 0.5`), from where it started to where it ended, each once
|
|
3412
|
+
*
|
|
3413
|
+
* @beta
|
|
3414
|
+
*/
|
|
3415
|
+
function wallLine(from, to) {
|
|
3416
|
+
const x0 = Math.floor(from.x);
|
|
3417
|
+
const z0 = Math.floor(from.z);
|
|
3418
|
+
const x1 = Math.floor(to.x);
|
|
3419
|
+
const z1 = Math.floor(to.z);
|
|
3420
|
+
const cells = [];
|
|
3421
|
+
const stepX = Math.sign(x1 - x0);
|
|
3422
|
+
const stepZ = Math.sign(z1 - z0);
|
|
3423
|
+
const alongX = Math.abs(x1 - x0) >= Math.abs(z1 - z0);
|
|
3424
|
+
let x = x0;
|
|
3425
|
+
let z = z0;
|
|
3426
|
+
cells.push({
|
|
3427
|
+
x: x + .5,
|
|
3428
|
+
z: z + .5
|
|
3429
|
+
});
|
|
3430
|
+
const walk = (axis) => {
|
|
3431
|
+
if (axis === "x") while (x !== x1) {
|
|
3432
|
+
x += stepX;
|
|
3433
|
+
cells.push({
|
|
3434
|
+
x: x + .5,
|
|
3435
|
+
z: z + .5
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
else while (z !== z1) {
|
|
3439
|
+
z += stepZ;
|
|
3440
|
+
cells.push({
|
|
3441
|
+
x: x + .5,
|
|
3442
|
+
z: z + .5
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
};
|
|
3446
|
+
walk(alongX ? "x" : "z");
|
|
3447
|
+
walk(alongX ? "z" : "x");
|
|
3448
|
+
return cells;
|
|
3449
|
+
}
|
|
3450
|
+
/**
|
|
3451
|
+
* Lays a wall along a drag with the player's builders: a build order for each cell of the line (`wallLine`), queued
|
|
3452
|
+
* after one another (after what they were doing, with `queue`), for the first builder of theirs among the ids that can
|
|
3453
|
+
* build the type. What cannot be built where it is (a unit, a tree in the way) is refused there as one built by hand is
|
|
3454
|
+
*
|
|
3455
|
+
* @beta
|
|
3456
|
+
*/
|
|
3457
|
+
var buildLineCommand = defineCommand("buildLine", {
|
|
3458
|
+
entities: ids,
|
|
3459
|
+
unitType: string,
|
|
3460
|
+
from: point,
|
|
3461
|
+
to: point,
|
|
3462
|
+
queue: optional(bool)
|
|
3463
|
+
}, (world, player, command) => {
|
|
3464
|
+
if (!world.registry.units.has(command.unitType)) return;
|
|
3465
|
+
const builder = command.entities.map((id) => world.get(id)).find((entity) => entity?.alive && entity.owner === player && entity.def.builds?.includes(command.unitType));
|
|
3466
|
+
if (!builder) return;
|
|
3467
|
+
wallLine(command.from, command.to).forEach((cell, index) => {
|
|
3468
|
+
giveOrder(world, builder, createOrder(world, {
|
|
3469
|
+
type: "build",
|
|
3470
|
+
unitType: command.unitType,
|
|
3471
|
+
point: cell
|
|
3472
|
+
}), index > 0 || !!command.queue);
|
|
3473
|
+
});
|
|
3474
|
+
});
|
|
3475
|
+
/**
|
|
3476
|
+
* A gate of a wall (`UnitTypeDef.wall.gate`): whether it stands open, every screen told. Open, it is in nobody's way,
|
|
3477
|
+
* whoever walks; shut, it is a piece of the wall. Its owner opens and shuts it (`wallGateCommand`, `setWallGate`)
|
|
3478
|
+
*
|
|
3479
|
+
* @beta
|
|
3480
|
+
*/
|
|
3481
|
+
var wallGate = defineComponent("walls.gate", {
|
|
3482
|
+
replicate: "everyone",
|
|
3483
|
+
onSpawn(entity) {
|
|
3484
|
+
if (!entity.def.wall?.gate) return;
|
|
3485
|
+
entity.set(wallGate, { open: false });
|
|
3486
|
+
entity.look = "shut";
|
|
3487
|
+
},
|
|
3488
|
+
passable: (gate) => gate.open
|
|
3489
|
+
});
|
|
3490
|
+
/**
|
|
3491
|
+
* A gate of a wall opened or shut (`setWallGate`)
|
|
3492
|
+
*
|
|
3493
|
+
* @beta
|
|
3494
|
+
*/
|
|
3495
|
+
var wallGateChanged = defineEvent("core.wallGateChanged", { replicate: "everyone" });
|
|
3496
|
+
/**
|
|
3497
|
+
* Opens or shuts a gate of a wall, for everyone: open, what walks goes through its cell, whoever's; shut, around it.
|
|
3498
|
+
* Its look is `open` or `shut`. False for what is no gate, or one still going up
|
|
3499
|
+
*
|
|
3500
|
+
* @beta
|
|
3501
|
+
*/
|
|
3502
|
+
function setWallGate(world, gate, open) {
|
|
3503
|
+
const now = gate.get(wallGate);
|
|
3504
|
+
if (!now || !gate.alive || gate.has(constructionSite)) return false;
|
|
3505
|
+
if (now.open === open) return true;
|
|
3506
|
+
gate.set(wallGate, { open });
|
|
3507
|
+
gate.look = open ? "open" : "shut";
|
|
3508
|
+
if (open) world.nav.removeObstacle(gate.id);
|
|
3509
|
+
else if (!world.nav.addObstacle(gate.id, gate.rect)) world.log.warn("navigation", `There is no room for another obstacle: the gate ${gate.id} does not block the way`, { entity: gate.id });
|
|
3510
|
+
world.events.emit(wallGateChanged, {
|
|
3511
|
+
entity: gate.id,
|
|
3512
|
+
open
|
|
3513
|
+
});
|
|
3514
|
+
return true;
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Its owner opens or shuts a gate of a wall (`setWallGate`), for everyone
|
|
3518
|
+
*
|
|
3519
|
+
* @beta
|
|
3520
|
+
*/
|
|
3521
|
+
var wallGateCommand = defineCommand("wallGate", {
|
|
3522
|
+
entity: id,
|
|
3523
|
+
open: bool
|
|
3524
|
+
}, (world, player, command) => {
|
|
3525
|
+
const gate = world.get(command.entity);
|
|
3526
|
+
if (gate?.owner === player) setWallGate(world, gate, command.open);
|
|
3527
|
+
});
|
|
3528
|
+
/**
|
|
3529
|
+
* Walls for a game: its pieces (types with `wall: { family }`) join with their neighbours as they are put down and
|
|
3530
|
+
* taken away, and a builder lays a line of them (`buildLineCommand`). A game's pieces draw by their shape with
|
|
3531
|
+
* `UnitLook.looks` (`single`, `end`, `straight`, `corner`, `tee`, `cross`); its gates (`wall.gate`) by `open` and `shut`,
|
|
3532
|
+
* opened and shut by their owner (`wallGateCommand`)
|
|
3533
|
+
*
|
|
3534
|
+
* @beta
|
|
3535
|
+
*/
|
|
3536
|
+
function withWalls() {
|
|
3537
|
+
const spawnHook = defineHook({
|
|
3538
|
+
hook: "spawn",
|
|
3539
|
+
id: "core.walls.spawn",
|
|
3540
|
+
run(world, entity) {
|
|
3541
|
+
const family = entity.def.wall?.family;
|
|
3542
|
+
if (family) reshapeAround(world, entity.rect, family, entity);
|
|
3543
|
+
}
|
|
3544
|
+
});
|
|
3545
|
+
const removeHook = defineHook({
|
|
3546
|
+
hook: "remove",
|
|
3547
|
+
id: "core.walls.remove",
|
|
3548
|
+
run(world, entity) {
|
|
3549
|
+
const family = entity.def.wall?.family;
|
|
3550
|
+
if (family) reshapeAround(world, entity.rect, family);
|
|
3551
|
+
}
|
|
3552
|
+
});
|
|
3553
|
+
return {
|
|
3554
|
+
id: "walls",
|
|
3555
|
+
commands: [buildLineCommand, wallGateCommand],
|
|
3556
|
+
components: [wallGate],
|
|
3557
|
+
events: [wallJoined, wallGateChanged],
|
|
3558
|
+
hooks: [spawnHook, removeHook]
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
//#endregion
|
|
3562
|
+
//#region src/mapMeasure.ts
|
|
3563
|
+
function walking(map, registry) {
|
|
3564
|
+
const { width, depth, levels, ramps } = map;
|
|
3565
|
+
const { open } = mapCells(map, registry, mapTerrain(map), true);
|
|
3566
|
+
const cellAt = (point) => {
|
|
3567
|
+
const cx = Math.floor(point.x);
|
|
3568
|
+
const cz = Math.floor(point.z);
|
|
3569
|
+
for (let r = 0; r <= 6; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) {
|
|
3570
|
+
if (x < 0 || z < 0 || x >= width || z >= depth || Math.max(Math.abs(x - cx), Math.abs(z - cz)) !== r) continue;
|
|
3571
|
+
if (open[z * width + x]) return z * width + x;
|
|
3572
|
+
}
|
|
3573
|
+
return -1;
|
|
3574
|
+
};
|
|
3575
|
+
return {
|
|
3576
|
+
width,
|
|
3577
|
+
depth,
|
|
3578
|
+
open,
|
|
3579
|
+
passable: (a, b) => levels[a] === levels[b] || ramps[a] > 0 || ramps[b] > 0,
|
|
3580
|
+
cellAt
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
/** The way on foot between two cells: A* over the cells, eight ways, corners not cut. Nothing when there is none */
|
|
3584
|
+
function walk(walking, from, to) {
|
|
3585
|
+
const { width, depth, open, passable } = walking;
|
|
3586
|
+
if (from < 0 || to < 0) return void 0;
|
|
3587
|
+
const cells = width * depth;
|
|
3588
|
+
const cost = new Float32Array(cells).fill(Infinity);
|
|
3589
|
+
const came = new Int32Array(cells).fill(-1);
|
|
3590
|
+
const closed = new Uint8Array(cells);
|
|
3591
|
+
const tx = to % width;
|
|
3592
|
+
const tz = Math.floor(to / width);
|
|
3593
|
+
const guess = (c) => {
|
|
3594
|
+
const dx = Math.abs(c % width - tx);
|
|
3595
|
+
const dz = Math.abs(Math.floor(c / width) - tz);
|
|
3596
|
+
return Math.max(dx, dz) + (Math.SQRT2 - 1) * Math.min(dx, dz);
|
|
3597
|
+
};
|
|
3598
|
+
const heap = [];
|
|
3599
|
+
const score = new Float32Array(cells);
|
|
3600
|
+
const push = (c) => {
|
|
3601
|
+
heap.push(c);
|
|
3602
|
+
let i = heap.length - 1;
|
|
3603
|
+
while (i > 0) {
|
|
3604
|
+
const parent = i - 1 >> 1;
|
|
3605
|
+
if (score[heap[parent]] <= score[heap[i]]) break;
|
|
3606
|
+
[heap[parent], heap[i]] = [heap[i], heap[parent]];
|
|
3607
|
+
i = parent;
|
|
3608
|
+
}
|
|
3609
|
+
};
|
|
3610
|
+
const pop = () => {
|
|
3611
|
+
const top = heap[0];
|
|
3612
|
+
const last = heap.pop();
|
|
3613
|
+
if (heap.length) {
|
|
3614
|
+
heap[0] = last;
|
|
3615
|
+
let i = 0;
|
|
3616
|
+
for (;;) {
|
|
3617
|
+
const left = i * 2 + 1;
|
|
3618
|
+
const right = left + 1;
|
|
3619
|
+
let least = i;
|
|
3620
|
+
if (left < heap.length && score[heap[left]] < score[heap[least]]) least = left;
|
|
3621
|
+
if (right < heap.length && score[heap[right]] < score[heap[least]]) least = right;
|
|
3622
|
+
if (least === i) break;
|
|
3623
|
+
[heap[least], heap[i]] = [heap[i], heap[least]];
|
|
3624
|
+
i = least;
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
return top;
|
|
3628
|
+
};
|
|
3629
|
+
cost[from] = 0;
|
|
3630
|
+
score[from] = guess(from);
|
|
3631
|
+
push(from);
|
|
3632
|
+
while (heap.length) {
|
|
3633
|
+
const c = pop();
|
|
3634
|
+
if (closed[c]) continue;
|
|
3635
|
+
if (c === to) break;
|
|
3636
|
+
closed[c] = 1;
|
|
3637
|
+
const x = c % width;
|
|
3638
|
+
const z = (c - x) / width;
|
|
3639
|
+
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
|
|
3640
|
+
if (!dx && !dz) continue;
|
|
3641
|
+
const nx = x + dx;
|
|
3642
|
+
const nz = z + dz;
|
|
3643
|
+
if (nx < 0 || nz < 0 || nx >= width || nz >= depth) continue;
|
|
3644
|
+
const n = nz * width + nx;
|
|
3645
|
+
if (!open[n] || closed[n] || !passable(c, n)) continue;
|
|
3646
|
+
if (dx && dz) {
|
|
3647
|
+
const side1 = z * width + nx;
|
|
3648
|
+
const side2 = nz * width + x;
|
|
3649
|
+
if (!open[side1] || !open[side2] || !passable(c, side1) || !passable(c, side2)) continue;
|
|
3650
|
+
}
|
|
3651
|
+
const next = cost[c] + (dx && dz ? Math.SQRT2 : 1);
|
|
3652
|
+
if (next >= cost[n]) continue;
|
|
3653
|
+
cost[n] = next;
|
|
3654
|
+
came[n] = c;
|
|
3655
|
+
score[n] = next + guess(n);
|
|
3656
|
+
push(n);
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
if (!Number.isFinite(cost[to])) return void 0;
|
|
3660
|
+
const points = [];
|
|
3661
|
+
for (let c = to; c >= 0; c = came[c]) points.push({
|
|
3662
|
+
x: c % width + .5,
|
|
3663
|
+
z: Math.floor(c / width) + .5
|
|
3664
|
+
});
|
|
3665
|
+
return {
|
|
3666
|
+
length: cost[to],
|
|
3667
|
+
points: points.reverse()
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
/**
|
|
3671
|
+
* The way on foot between two points of a map, with what the checks count as in the way. Nothing when there is none
|
|
3672
|
+
*
|
|
3673
|
+
* @beta
|
|
3674
|
+
*/
|
|
3675
|
+
function walkBetween(map, registry, from, to) {
|
|
3676
|
+
const cells = walking(map, registry);
|
|
3677
|
+
return walk(cells, cells.cellAt(from), cells.cellAt(to));
|
|
3678
|
+
}
|
|
3679
|
+
/**
|
|
3680
|
+
* How many cells each open cell is from what is not: half the width of the way there. A passage two cells wide has
|
|
3681
|
+
* cells 1 from its sides in its middle
|
|
3682
|
+
*/
|
|
3683
|
+
function clearance(walking) {
|
|
3684
|
+
const { width, depth, open } = walking;
|
|
3685
|
+
const far = new Float32Array(width * depth);
|
|
3686
|
+
for (let c = 0; c < far.length; c++) far[c] = open[c] ? Infinity : 0;
|
|
3687
|
+
const step = (c, x, z, dx, dz, weight) => {
|
|
3688
|
+
const nx = x + dx;
|
|
3689
|
+
const nz = z + dz;
|
|
3690
|
+
const next = nx < 0 || nz < 0 || nx >= width || nz >= depth ? 0 : far[nz * width + nx];
|
|
3691
|
+
if (next + weight < far[c]) far[c] = next + weight;
|
|
3692
|
+
};
|
|
3693
|
+
for (let z = 0; z < depth; z++) for (let x = 0; x < width; x++) {
|
|
3694
|
+
const c = z * width + x;
|
|
3695
|
+
if (!far[c]) continue;
|
|
3696
|
+
step(c, x, z, -1, 0, 1);
|
|
3697
|
+
step(c, x, z, 0, -1, 1);
|
|
3698
|
+
step(c, x, z, -1, -1, Math.SQRT2);
|
|
3699
|
+
step(c, x, z, 1, -1, Math.SQRT2);
|
|
3700
|
+
}
|
|
3701
|
+
for (let z = depth - 1; z >= 0; z--) for (let x = width - 1; x >= 0; x--) {
|
|
3702
|
+
const c = z * width + x;
|
|
3703
|
+
if (!far[c]) continue;
|
|
3704
|
+
step(c, x, z, 1, 0, 1);
|
|
3705
|
+
step(c, x, z, 0, 1, 1);
|
|
3706
|
+
step(c, x, z, 1, 1, Math.SQRT2);
|
|
3707
|
+
step(c, x, z, -1, 1, Math.SQRT2);
|
|
3708
|
+
}
|
|
3709
|
+
return far;
|
|
3710
|
+
}
|
|
3711
|
+
/**
|
|
3712
|
+
* How far around a start counts as its ground, for building and trees
|
|
3713
|
+
*
|
|
3714
|
+
* @beta
|
|
3715
|
+
*/
|
|
3716
|
+
var BUILD_REACH = 16;
|
|
3717
|
+
/**
|
|
3718
|
+
* How much more one start can have of something than another before it is not fair, as a part
|
|
3719
|
+
*
|
|
3720
|
+
* @internal
|
|
3721
|
+
*/
|
|
3722
|
+
var FAIR_MARGIN = .15;
|
|
3723
|
+
/** The ends of a way, in cells, where the bases stand: the narrowest place is looked for between them */
|
|
3724
|
+
var BASE_ROOM = 8;
|
|
3725
|
+
/**
|
|
3726
|
+
* What a map is like to play on: the way on foot between every two starts, the narrowest place on those ways, and for
|
|
3727
|
+
* each start the nearest mine and how much it holds, the ground around it that can be built on, and its trees; side
|
|
3728
|
+
* by side, with what one start has much more of than another said. Then what the game measures of its own
|
|
3729
|
+
*
|
|
3730
|
+
* @beta
|
|
3731
|
+
*/
|
|
3732
|
+
function measureMap(map, registry) {
|
|
3733
|
+
const cells = walking(map, registry);
|
|
3734
|
+
const terrain = mapTerrain(map);
|
|
3735
|
+
const grid = new BuildGrid(terrain);
|
|
3736
|
+
const { built } = mapCells(map, registry, terrain);
|
|
3737
|
+
const starts = map.players.filter((player) => player.start);
|
|
3738
|
+
const rushes = [];
|
|
3739
|
+
for (let i = 0; i < starts.length; i++) for (let j = i + 1; j < starts.length; j++) {
|
|
3740
|
+
const way = walk(cells, cells.cellAt(starts[i].start), cells.cellAt(starts[j].start));
|
|
3741
|
+
rushes.push({
|
|
3742
|
+
from: starts[i].id,
|
|
3743
|
+
to: starts[j].id,
|
|
3744
|
+
...way && {
|
|
3745
|
+
length: Math.round(way.length * 10) / 10,
|
|
3746
|
+
points: way.points
|
|
3747
|
+
}
|
|
3748
|
+
});
|
|
3749
|
+
}
|
|
3750
|
+
const far = clearance(cells);
|
|
3751
|
+
let narrowest;
|
|
3752
|
+
for (const rush of rushes) {
|
|
3753
|
+
const points = rush.points ?? [];
|
|
3754
|
+
for (let i = BASE_ROOM; i < points.length - BASE_ROOM; i++) {
|
|
3755
|
+
const c = Math.floor(points[i].z) * cells.width + Math.floor(points[i].x);
|
|
3756
|
+
const width = Math.round((far[c] * 2 - 1) * 10) / 10;
|
|
3757
|
+
if (!narrowest || width < narrowest.width) narrowest = {
|
|
3758
|
+
width,
|
|
3759
|
+
at: points[i]
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
const mines = map.objects.filter((object) => {
|
|
3764
|
+
if (!registry.units.has(object.type)) return false;
|
|
3765
|
+
const def = registry.unit(object.type);
|
|
3766
|
+
return def.kind === "resource" && footprintOf(def).w >= 2;
|
|
3767
|
+
});
|
|
3768
|
+
const measures = starts.map((player) => {
|
|
3769
|
+
const start = player.start;
|
|
3770
|
+
const from = cells.cellAt(start);
|
|
3771
|
+
let mine;
|
|
3772
|
+
for (const object of mines) {
|
|
3773
|
+
const way = walk(cells, from, cells.cellAt(object));
|
|
3774
|
+
if (!way || mine && way.length >= mine.distance) continue;
|
|
3775
|
+
const def = registry.unit(object.type);
|
|
3776
|
+
mine = {
|
|
3777
|
+
distance: Math.round(way.length * 10) / 10,
|
|
3778
|
+
amount: object.amount ?? def.resource?.amount ?? 0,
|
|
3779
|
+
at: {
|
|
3780
|
+
x: object.x,
|
|
3781
|
+
z: object.z
|
|
3782
|
+
}
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
let buildable = 0;
|
|
3786
|
+
for (let z = Math.floor(start.z - 16); z <= Math.ceil(start.z + 16); z++) for (let x = Math.floor(start.x - 16); x <= Math.ceil(start.x + 16); x++) {
|
|
3787
|
+
if (hypot(x + .5 - start.x, z + .5 - start.z) > 16) continue;
|
|
3788
|
+
if (grid.isBuildable(x, z) && !built[z * map.width + x]) buildable++;
|
|
3789
|
+
}
|
|
3790
|
+
const trees = map.objects.filter((object) => {
|
|
3791
|
+
if (!registry.units.has(object.type) || hypot(object.x - start.x, object.z - start.z) > 16) return false;
|
|
3792
|
+
const def = registry.unit(object.type);
|
|
3793
|
+
return def.kind === "resource" && footprintOf(def).w < 2;
|
|
3794
|
+
}).length;
|
|
3795
|
+
return {
|
|
3796
|
+
player: player.id,
|
|
3797
|
+
name: player.name,
|
|
3798
|
+
...mine && { mine },
|
|
3799
|
+
buildable,
|
|
3800
|
+
trees
|
|
3801
|
+
};
|
|
3802
|
+
});
|
|
3803
|
+
const unfair = [];
|
|
3804
|
+
const compare = (what, value, more = "more") => {
|
|
3805
|
+
const known = measures.map((start) => ({
|
|
3806
|
+
start,
|
|
3807
|
+
value: value(start)
|
|
3808
|
+
})).filter((entry) => entry.value !== void 0);
|
|
3809
|
+
if (known.length < 2) return;
|
|
3810
|
+
const most = known.reduce((a, b) => b.value > a.value ? b : a);
|
|
3811
|
+
const least = known.reduce((a, b) => b.value < a.value ? b : a);
|
|
3812
|
+
if (most.value > least.value * 1.15 && most.value - least.value > 1) unfair.push(`${most.start.name} has ${more} ${what} than ${least.start.name}: ${most.value} against ${least.value}`);
|
|
3813
|
+
};
|
|
3814
|
+
compare("way to their nearest mine", (start) => start.mine?.distance, "a longer");
|
|
3815
|
+
compare("gold in their nearest mine", (start) => start.mine?.amount);
|
|
3816
|
+
compare("ground to build on", (start) => start.buildable);
|
|
3817
|
+
compare("trees near their start", (start) => start.trees);
|
|
3818
|
+
return {
|
|
3819
|
+
rushes: rushes.map(({ points: _, ...rush }) => rush),
|
|
3820
|
+
...narrowest && { narrowest },
|
|
3821
|
+
starts: measures,
|
|
3822
|
+
unfair,
|
|
3823
|
+
game: registry.module?.editor?.measure?.(map, registry) ?? []
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Seconds a unit type takes over a way on foot, at the speed it starts with
|
|
3828
|
+
*
|
|
3829
|
+
* @beta
|
|
3830
|
+
*/
|
|
3831
|
+
var walkingTime = (registry, type, length) => {
|
|
3832
|
+
if (!registry.units.has(type)) return void 0;
|
|
3833
|
+
const speed = registry.unit(type).stats.moveSpeed ?? 0;
|
|
3834
|
+
return speed > 0 ? Math.round(length / speed * 10) / 10 : void 0;
|
|
3835
|
+
};
|
|
3836
|
+
//#endregion
|
|
3837
|
+
//#region src/scenario.ts
|
|
3838
|
+
/**
|
|
3839
|
+
* The format scenarios are written in now
|
|
3840
|
+
*
|
|
3841
|
+
* @beta
|
|
3842
|
+
*/
|
|
3843
|
+
var SCENARIO_FORMAT = 1;
|
|
3844
|
+
/**
|
|
3845
|
+
* A scenario file as it is now, checked. Throws on what is wrong with it
|
|
3846
|
+
*
|
|
3847
|
+
* @beta
|
|
3848
|
+
*/
|
|
3849
|
+
function readScenario(data) {
|
|
3850
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("A scenario is an object");
|
|
3851
|
+
const file = data;
|
|
3852
|
+
if (typeof file.format !== "number" || file.format > 1) throw new Error(`A scenario of format ${file.format}, which this engine does not know`);
|
|
3853
|
+
if (typeof file.name !== "string" || !file.name) throw new Error("A scenario has a name");
|
|
3854
|
+
if (typeof file.game !== "string" || !file.game) throw new Error(`The scenario ${file.name} does not say which game it is of`);
|
|
3855
|
+
if (file.kind === "map") parseMap(file.map);
|
|
3856
|
+
else if (file.kind === "save") {
|
|
3857
|
+
if (typeof file.save !== "string") throw new Error(`The scenario ${file.name} has no save in it`);
|
|
3858
|
+
} else throw new Error(`The scenario ${file.name} is neither a map nor a save`);
|
|
3859
|
+
return {
|
|
3860
|
+
...file,
|
|
3861
|
+
format: 1
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
/**
|
|
3865
|
+
* A map made for a test, as a scenario
|
|
3866
|
+
*
|
|
3867
|
+
* @beta
|
|
3868
|
+
*/
|
|
3869
|
+
function scenarioOfMap(map, name, game, description) {
|
|
3870
|
+
return {
|
|
3871
|
+
format: 1,
|
|
3872
|
+
name,
|
|
3873
|
+
game,
|
|
3874
|
+
...description && { description },
|
|
3875
|
+
kind: "map",
|
|
3876
|
+
map: serializeMap(map)
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* A world at the moment that matters, as a scenario
|
|
3881
|
+
*
|
|
3882
|
+
* @beta
|
|
3883
|
+
*/
|
|
3884
|
+
function scenarioOfWorld(world, name, description) {
|
|
3885
|
+
return {
|
|
3886
|
+
format: 1,
|
|
3887
|
+
name,
|
|
3888
|
+
game: world.registry.game,
|
|
3889
|
+
...description && { description },
|
|
3890
|
+
kind: "save",
|
|
3891
|
+
save: encodeSave(portableSave(world.registry, saveWorld(world)))
|
|
3892
|
+
};
|
|
3893
|
+
}
|
|
3894
|
+
/** Only what is the game's: what an app keeps of its own in the world (`Component.portable` false) left out */
|
|
3895
|
+
function portableSave(registry, save) {
|
|
3896
|
+
const keep = (values) => values && Object.fromEntries(Object.entries(values).filter(([id]) => registry.component(id)?.portable !== false));
|
|
3897
|
+
return {
|
|
3898
|
+
...save,
|
|
3899
|
+
state: keep(save.state),
|
|
3900
|
+
players: save.players.map((player) => ({
|
|
3901
|
+
...player,
|
|
3902
|
+
state: keep(player.state)
|
|
3903
|
+
})),
|
|
3904
|
+
entities: save.entities.map((entity) => entity.components ? {
|
|
3905
|
+
...entity,
|
|
3906
|
+
components: keep(entity.components)
|
|
3907
|
+
} : entity),
|
|
3908
|
+
detached: save.detached.map((entity) => entity.components ? {
|
|
3909
|
+
...entity,
|
|
3910
|
+
components: keep(entity.components)
|
|
3911
|
+
} : entity)
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* The world of a scenario, with the game's module: a map made into a world as a game would make it (with the seed 1,
|
|
3916
|
+
* so that it is the same every time), or a saved world loaded as it was
|
|
3917
|
+
*
|
|
3918
|
+
* @beta
|
|
3919
|
+
*/
|
|
3920
|
+
async function scenarioWorld(scenario, game) {
|
|
3921
|
+
if (scenario.game !== game.id) throw new Error(`The scenario ${scenario.name} is of the game "${scenario.game}", not "${game.id}"`);
|
|
3922
|
+
if (scenario.kind === "map") return createWorldFromMap({
|
|
3923
|
+
map: parseMap(scenario.map),
|
|
3924
|
+
game,
|
|
3925
|
+
seed: 1
|
|
3926
|
+
});
|
|
3927
|
+
return loadWorld(decodeSave(scenario.save), { game });
|
|
3928
|
+
}
|
|
3929
|
+
//#endregion
|
|
3930
|
+
//#region src/packs/heroes/carry.ts
|
|
3931
|
+
/**
|
|
3932
|
+
* A hero of a world as a record, to carry into another (see `applyHeroRecord`). Throws for what is no hero
|
|
3933
|
+
*
|
|
3934
|
+
* @beta
|
|
3935
|
+
*/
|
|
3936
|
+
function heroRecord(world, hero) {
|
|
3937
|
+
const state = heroOf(hero);
|
|
3938
|
+
if (!state) throw new Error(`${hero.def.id} is no hero to carry over`);
|
|
3939
|
+
const name = world.nameOf(hero);
|
|
3940
|
+
return {
|
|
3941
|
+
type: hero.def.id,
|
|
3942
|
+
...name !== void 0 && { name },
|
|
3943
|
+
level: state.level,
|
|
3944
|
+
xp: state.xp,
|
|
3945
|
+
points: state.points,
|
|
3946
|
+
abilities: Object.fromEntries(hero.abilities.filter((ability) => ability.level > 0).map((ability) => [ability.id, ability.level])),
|
|
3947
|
+
items: (slotsOf(hero) ?? []).map((slot) => slot ? {
|
|
3948
|
+
item: slot.item,
|
|
3949
|
+
charges: slot.charges,
|
|
3950
|
+
...slot.data && { data: structuredClone(slot.data) }
|
|
3951
|
+
} : null)
|
|
3952
|
+
};
|
|
3953
|
+
}
|
|
3954
|
+
/**
|
|
3955
|
+
* Makes a hero of a world what a record says it was: its level, experience and points, its abilities as far as they
|
|
3956
|
+
* were learned, and its items in their slots. In place of what it had. What the game no longer has, an ability or an
|
|
3957
|
+
* item, is left out. Whole: health and mana full, nothing cooling down
|
|
3958
|
+
*
|
|
3959
|
+
* @beta
|
|
3960
|
+
*/
|
|
3961
|
+
function applyHeroRecord(world, hero, record) {
|
|
3962
|
+
const state = heroOf(hero);
|
|
3963
|
+
if (!state) throw new Error(`${hero.def.id} is no hero to carry a hero into`);
|
|
3964
|
+
state.level = Math.max(1, Math.min(Math.floor(record.level), maxLevelOf(world, hero)));
|
|
3965
|
+
state.xp = Math.max(record.xp, world.rules.heroes.xpForLevel(state.level));
|
|
3966
|
+
state.points = Math.max(Math.floor(record.points), 0);
|
|
3967
|
+
for (const ability of hero.abilities) {
|
|
3968
|
+
const level = record.abilities[ability.id] ?? 0;
|
|
3969
|
+
const max = world.registry.abilities.has(ability.id) ? world.registry.ability(ability.id).maxLevel ?? 3 : 0;
|
|
3970
|
+
ability.level = Math.min(level, max);
|
|
3971
|
+
readyAgain(ability);
|
|
3972
|
+
ability.autocast = ability.level > 0 && (world.registry.ability(ability.id).autocast ?? false);
|
|
3973
|
+
}
|
|
3974
|
+
const slots = slotsOf(hero);
|
|
3975
|
+
if (slots) {
|
|
3976
|
+
slots.fill(null);
|
|
3977
|
+
record.items.forEach((carried, slot) => {
|
|
3978
|
+
if (!carried || slot >= slots.length || !world.registry.items.has(carried.item)) return;
|
|
3979
|
+
const instance = createItem(world, carried.item, carried.charges);
|
|
3980
|
+
if (carried.data) instance.data = structuredClone(carried.data);
|
|
3981
|
+
slots[slot] = instance;
|
|
3982
|
+
});
|
|
3983
|
+
world.events.emit(inventoryChanged, { entity: hero });
|
|
3984
|
+
}
|
|
3985
|
+
refreshVitals(world, hero);
|
|
3986
|
+
hero.hp = hero.maxHp;
|
|
3987
|
+
hero.mana = hero.maxMana;
|
|
3988
|
+
}
|
|
3989
|
+
/**
|
|
3990
|
+
* A hero made from a record (see `heroRecord`) for a player at a point, under the name it had unless told another
|
|
3991
|
+
*
|
|
3992
|
+
* @beta
|
|
3993
|
+
*/
|
|
3994
|
+
function heroFromRecord(world, record, owner, at, options = {}) {
|
|
3995
|
+
const name = options.name ?? record.name;
|
|
3996
|
+
const hero = world.spawn(record.type, owner, at, {
|
|
3997
|
+
...options,
|
|
3998
|
+
...name !== void 0 && { name }
|
|
3999
|
+
});
|
|
4000
|
+
applyHeroRecord(world, hero, record);
|
|
4001
|
+
return hero;
|
|
4002
|
+
}
|
|
4003
|
+
/**
|
|
4004
|
+
* A record read back from what was kept (a campaign's progress, a file), checked. Throws on what is wrong
|
|
4005
|
+
*
|
|
4006
|
+
* @beta
|
|
4007
|
+
*/
|
|
4008
|
+
function readHeroRecord(data) {
|
|
4009
|
+
const record = data;
|
|
4010
|
+
if (typeof record !== "object" || record === null) throw new Error("A hero record is an object");
|
|
4011
|
+
if (typeof record.type !== "string" || !record.type) throw new Error("A hero record says what type of unit it is");
|
|
4012
|
+
const number = (value, what) => {
|
|
4013
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`A hero record's ${what} is a number`);
|
|
4014
|
+
return value;
|
|
4015
|
+
};
|
|
4016
|
+
const abilities = record.abilities ?? {};
|
|
4017
|
+
if (typeof abilities !== "object" || Array.isArray(abilities)) throw new Error("A hero record's abilities are levels by id");
|
|
4018
|
+
return {
|
|
4019
|
+
type: record.type,
|
|
4020
|
+
...typeof record.name === "string" && { name: record.name },
|
|
4021
|
+
level: number(record.level, "level"),
|
|
4022
|
+
xp: number(record.xp ?? 0, "experience"),
|
|
4023
|
+
points: number(record.points ?? 0, "points"),
|
|
4024
|
+
abilities: Object.fromEntries(Object.entries(abilities).map(([id, level]) => [id, number(level, `level of ${id}`)])),
|
|
4025
|
+
items: (Array.isArray(record.items) ? record.items : []).map((slot) => slot && typeof slot === "object" && typeof slot.item === "string" ? {
|
|
4026
|
+
item: slot.item,
|
|
4027
|
+
charges: number(slot.charges ?? 0, "charges"),
|
|
4028
|
+
...slot.data && { data: slot.data }
|
|
4029
|
+
} : null)
|
|
4030
|
+
};
|
|
4031
|
+
}
|
|
4032
|
+
//#endregion
|
|
4033
|
+
//#region src/packs/heroes/campaign.ts
|
|
4034
|
+
/**
|
|
4035
|
+
* The format campaign progress is written in now
|
|
4036
|
+
*
|
|
4037
|
+
* @internal
|
|
4038
|
+
*/
|
|
4039
|
+
var CAMPAIGN_FORMAT = 1;
|
|
4040
|
+
/**
|
|
4041
|
+
* A campaign as it is before anything is played: at its first mission, with nothing carried
|
|
4042
|
+
*
|
|
4043
|
+
* @beta
|
|
4044
|
+
*/
|
|
4045
|
+
function newCampaignProgress(campaign, first) {
|
|
4046
|
+
return {
|
|
4047
|
+
format: 1,
|
|
4048
|
+
campaign,
|
|
4049
|
+
at: first,
|
|
4050
|
+
done: {},
|
|
4051
|
+
heroes: [],
|
|
4052
|
+
data: {}
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
4055
|
+
/**
|
|
4056
|
+
* Progress as it was kept, checked and brought up to date. Throws on what is wrong
|
|
4057
|
+
*
|
|
4058
|
+
* @beta
|
|
4059
|
+
*/
|
|
4060
|
+
function readCampaignProgress(data) {
|
|
4061
|
+
const kept = data;
|
|
4062
|
+
if (typeof kept !== "object" || kept === null) throw new Error("A campaign's progress is an object");
|
|
4063
|
+
if (typeof kept.format !== "number" || kept.format > 1) throw new Error(`Campaign progress of format ${kept.format}, which this engine does not know`);
|
|
4064
|
+
if (typeof kept.campaign !== "string" || typeof kept.at !== "string") throw new Error("A campaign's progress says which campaign it is and where it is at");
|
|
4065
|
+
const done = {};
|
|
4066
|
+
for (const [mission, result] of Object.entries(kept.done ?? {})) {
|
|
4067
|
+
const { seconds, best, finished } = result ?? {};
|
|
4068
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) throw new Error(`How long ${mission} took is a number`);
|
|
4069
|
+
done[mission] = {
|
|
4070
|
+
seconds,
|
|
4071
|
+
best: typeof best === "number" ? best : seconds,
|
|
4072
|
+
finished: typeof finished === "number" ? finished : 0
|
|
4073
|
+
};
|
|
4074
|
+
}
|
|
4075
|
+
return {
|
|
4076
|
+
format: 1,
|
|
4077
|
+
campaign: kept.campaign,
|
|
4078
|
+
at: kept.at,
|
|
4079
|
+
...typeof kept.difficulty === "string" && { difficulty: kept.difficulty },
|
|
4080
|
+
done,
|
|
4081
|
+
heroes: (kept.heroes ?? []).map(readHeroRecord),
|
|
4082
|
+
data: typeof kept.data === "object" && kept.data !== null ? kept.data : {}
|
|
4083
|
+
};
|
|
4084
|
+
}
|
|
4085
|
+
/**
|
|
4086
|
+
* A mission won: how long it took, the best time kept, the heroes it ends with to carry on, the campaign at the
|
|
4087
|
+
* mission after it (`next`), or still at this one when it was the last, and what it carries of the game's own
|
|
4088
|
+
* (`data`: its keys over the ones kept, the rest kept as they were). A new progress: the one given is left as it was
|
|
4089
|
+
*
|
|
4090
|
+
* @beta
|
|
4091
|
+
*/
|
|
4092
|
+
function missionWon(progress, mission, seconds, heroes, next, data) {
|
|
4093
|
+
const before = progress.done[mission];
|
|
4094
|
+
return {
|
|
4095
|
+
...progress,
|
|
4096
|
+
at: next ?? progress.at,
|
|
4097
|
+
done: {
|
|
4098
|
+
...progress.done,
|
|
4099
|
+
[mission]: {
|
|
4100
|
+
seconds,
|
|
4101
|
+
best: before ? Math.min(before.best, seconds) : seconds,
|
|
4102
|
+
finished: Date.now()
|
|
4103
|
+
}
|
|
4104
|
+
},
|
|
4105
|
+
heroes: structuredClone(heroes),
|
|
4106
|
+
data: data ? {
|
|
4107
|
+
...progress.data,
|
|
4108
|
+
...structuredClone(data)
|
|
4109
|
+
} : progress.data
|
|
4110
|
+
};
|
|
4111
|
+
}
|
|
4112
|
+
/**
|
|
4113
|
+
* A player's units as records to carry on (`UnitRecord`): those alive, not structures, not heroes (`carryOut` carries
|
|
4114
|
+
* those), and of them what `filter` keeps (the footmen, the three nearest the gate). In the order of their ids
|
|
4115
|
+
*
|
|
4116
|
+
* @beta
|
|
4117
|
+
*/
|
|
4118
|
+
function carryUnits(world, player, filter = () => true) {
|
|
4119
|
+
return [...world.index.ownedBy(player)].filter((unit) => unit.alive && unit.isUnit && !heroOf(unit) && filter(unit)).sort((a, b) => a.id - b.id).map((unit) => {
|
|
4120
|
+
const name = world.nameOf(unit);
|
|
4121
|
+
const rank = unit.get(veterancy);
|
|
4122
|
+
const slots = slotsOf(unit);
|
|
4123
|
+
return {
|
|
4124
|
+
type: unit.def.id,
|
|
4125
|
+
...name !== void 0 && { name },
|
|
4126
|
+
health: unit.maxHp > 0 ? Math.min(Math.max(unit.hp / unit.maxHp, 0), 1) : 1,
|
|
4127
|
+
...rank && { veterancy: { ...rank } },
|
|
4128
|
+
...slots && { items: slots.map((slot) => slot ? {
|
|
4129
|
+
item: slot.item,
|
|
4130
|
+
charges: slot.charges,
|
|
4131
|
+
...slot.data && { data: structuredClone(slot.data) }
|
|
4132
|
+
} : null) }
|
|
4133
|
+
};
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
/**
|
|
4137
|
+
* Brings carried units into a mission for a player, round a point (`spread` apart, in rings: 1.5 when left out), each
|
|
4138
|
+
* with its name, its share of health, its veterancy and its items. What the game no longer has, a type or an item, is
|
|
4139
|
+
* left out. The units made, in the order of the records
|
|
4140
|
+
*
|
|
4141
|
+
* @beta
|
|
4142
|
+
*/
|
|
4143
|
+
function placeUnits(world, player, records, at, spread = 1.5) {
|
|
4144
|
+
const made = [];
|
|
4145
|
+
records.filter((record) => world.registry.units.has(record.type)).forEach((record, i) => {
|
|
4146
|
+
const point = ringPoint(at, i, spread);
|
|
4147
|
+
const unit = world.spawn(record.type, player, point, record.name !== void 0 ? { name: record.name } : {});
|
|
4148
|
+
if (record.veterancy) {
|
|
4149
|
+
unit.set(veterancy, { ...record.veterancy });
|
|
4150
|
+
refreshVitals(world, unit);
|
|
4151
|
+
}
|
|
4152
|
+
const slots = slotsOf(unit);
|
|
4153
|
+
if (slots && record.items) {
|
|
4154
|
+
slots.fill(null);
|
|
4155
|
+
record.items.forEach((carried, slot) => {
|
|
4156
|
+
if (!carried || slot >= slots.length || !world.registry.items.has(carried.item)) return;
|
|
4157
|
+
const instance = createItem(world, carried.item, carried.charges);
|
|
4158
|
+
if (carried.data) instance.data = structuredClone(carried.data);
|
|
4159
|
+
slots[slot] = instance;
|
|
4160
|
+
});
|
|
4161
|
+
world.events.emit(inventoryChanged, { entity: unit });
|
|
4162
|
+
}
|
|
4163
|
+
unit.hp = Math.max(1, Math.round(unit.maxHp * Math.min(Math.max(record.health, 0), 1)));
|
|
4164
|
+
made.push(unit);
|
|
4165
|
+
});
|
|
4166
|
+
return made;
|
|
4167
|
+
}
|
|
4168
|
+
/** The `index`-th place round a point: the point first, then rings of six, twelve, … `spread` apart */
|
|
4169
|
+
function ringPoint(at, index, spread) {
|
|
4170
|
+
if (index === 0) return {
|
|
4171
|
+
x: at.x,
|
|
4172
|
+
z: at.z
|
|
4173
|
+
};
|
|
4174
|
+
let ring = 1;
|
|
4175
|
+
let first = 1;
|
|
4176
|
+
while (index >= first + ring * 6) {
|
|
4177
|
+
first += ring * 6;
|
|
4178
|
+
ring++;
|
|
4179
|
+
}
|
|
4180
|
+
const angle = (index - first) / (ring * 6) * Math.PI * 2;
|
|
4181
|
+
return {
|
|
4182
|
+
x: at.x + sin(angle) * ring * spread,
|
|
4183
|
+
z: at.z + cos(angle) * ring * spread
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* What a player has researched, to carry on (`giveResearch`): each upgrade's level, by its id
|
|
4188
|
+
*
|
|
4189
|
+
* @beta
|
|
4190
|
+
*/
|
|
4191
|
+
function carryResearch(world, player) {
|
|
4192
|
+
return Object.fromEntries([...world.player(player).upgrades].filter(([, level]) => level > 0));
|
|
4193
|
+
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Gives a player research carried in (`carryResearch`): each upgrade up to its level, never down, no further than it
|
|
4196
|
+
* goes, and what it does besides stats (`UpgradeDef.apply`) to the units the player has, as research finishing does.
|
|
4197
|
+
* What the game no longer has is left out
|
|
4198
|
+
*
|
|
4199
|
+
* @beta
|
|
4200
|
+
*/
|
|
4201
|
+
function giveResearch(world, player, research) {
|
|
4202
|
+
const who = world.player(player);
|
|
4203
|
+
for (const [upgrade, carried] of Object.entries(research)) {
|
|
4204
|
+
if (!world.registry.upgrades.has(upgrade)) continue;
|
|
4205
|
+
const def = world.registry.upgrade(upgrade);
|
|
4206
|
+
const to = Math.min(Math.max(Math.floor(carried), 0), def.maxLevel ?? 1);
|
|
4207
|
+
for (let level = (who.upgrades.get(upgrade) ?? 0) + 1; level <= to; level++) {
|
|
4208
|
+
who.setUpgrade(upgrade, level);
|
|
4209
|
+
if (def.apply) {
|
|
4210
|
+
for (const entity of [...world.index.ownedBy(player)]) if (entity.alive) def.apply(world, entity, level);
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
who.researching.delete(upgrade);
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
/**
|
|
4217
|
+
* What a player has of each resource, to carry on (`giveResources`): a copy, all of it, or of the kinds named
|
|
4218
|
+
*
|
|
4219
|
+
* @beta
|
|
4220
|
+
*/
|
|
4221
|
+
function carryResources(world, player, kinds) {
|
|
4222
|
+
const resources = world.player(player).resources;
|
|
4223
|
+
return Object.fromEntries(Object.entries(resources).filter(([kind]) => !kinds || kinds.includes(kind)));
|
|
4224
|
+
}
|
|
4225
|
+
/**
|
|
4226
|
+
* Gives a player resources carried in (`carryResources`), on top of what the mission starts it with. What the game no
|
|
4227
|
+
* longer has is left out
|
|
4228
|
+
*
|
|
4229
|
+
* @beta
|
|
4230
|
+
*/
|
|
4231
|
+
function giveResources(world, player, resources) {
|
|
4232
|
+
const purse = world.player(player).resources;
|
|
4233
|
+
const known = new Set(world.registry.resources.map((resource) => resource.id));
|
|
4234
|
+
for (const [kind, amount] of Object.entries(resources)) if (known.has(kind) && Number.isFinite(amount)) purse[kind] = (purse[kind] ?? 0) + amount;
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* The heroes of a player as records to carry on (see `heroRecord`): fallen ones too, as a campaign brings them back
|
|
4238
|
+
*
|
|
4239
|
+
* @beta
|
|
4240
|
+
*/
|
|
4241
|
+
function carryOut(world, player) {
|
|
4242
|
+
return heroesOf(world, player).map((hero) => heroRecord(world, hero));
|
|
4243
|
+
}
|
|
4244
|
+
/**
|
|
4245
|
+
* Brings carried heroes into a mission for a player. Each takes the place of the hero the map has for it: the one of
|
|
4246
|
+
* that player under the same name, else one of that player of the same type; a hero the map has none for is made at
|
|
4247
|
+
* `at`. The heroes, in the order of the records
|
|
4248
|
+
*
|
|
4249
|
+
* @beta
|
|
4250
|
+
*/
|
|
4251
|
+
function carryIn(world, heroes, player, at) {
|
|
4252
|
+
const taken = /* @__PURE__ */ new Set();
|
|
4253
|
+
return heroes.map((record) => {
|
|
4254
|
+
const named = record.name !== void 0 ? world.named(record.name) : void 0;
|
|
4255
|
+
const placed = named && named.owner === player && heroOf(named) && !taken.has(named) ? named : heroesOf(world, player).find((hero) => hero.def.id === record.type && !taken.has(hero));
|
|
4256
|
+
const hero = placed ?? heroFromRecord(world, record, player, at);
|
|
4257
|
+
if (placed) applyHeroRecord(world, placed, record);
|
|
4258
|
+
taken.add(hero);
|
|
4259
|
+
return hero;
|
|
4260
|
+
});
|
|
4261
|
+
}
|
|
4262
|
+
//#endregion
|
|
4263
|
+
//#region src/session/commandReplay.ts
|
|
4264
|
+
/**
|
|
4265
|
+
* Replays of commands (determinism D4): a game kept as where it started and what its players told it, tick by tick,
|
|
4266
|
+
* and played back by running it again. A world comes out the same from the same start and the same commands (D1–D3),
|
|
4267
|
+
* so this is the whole game, from every side, in a few kilobytes where the frames a watcher was sent are megabytes.
|
|
4268
|
+
* Its hash every few seconds is kept with it: played back, a hash that differs says the game came apart there (rules
|
|
4269
|
+
* changed since, or a system a save does not bring back), and from then it is the game the rules make now
|
|
4270
|
+
*/
|
|
4271
|
+
/**
|
|
4272
|
+
* Records a world from now as its start and the commands given to it from outside a tick (the players', by a
|
|
4273
|
+
* session or a server; a computer player's are its system's, which runs again), with its hash every `every` ticks
|
|
4274
|
+
* (300, ten seconds at 30 a second, when left out). Starting takes a save of the world, as saving does (it puts the
|
|
4275
|
+
* crowd back as a load does)
|
|
4276
|
+
*
|
|
4277
|
+
* @beta
|
|
4278
|
+
*/
|
|
4279
|
+
function recordCommands(world, options) {
|
|
4280
|
+
const every = options.every ?? 300;
|
|
4281
|
+
const checkpointEvery = options.checkpoints ?? 0;
|
|
4282
|
+
const checkpoints = [];
|
|
4283
|
+
const from = world.tick;
|
|
4284
|
+
const start = encodeSave(saveWorld(world));
|
|
4285
|
+
const commands = [];
|
|
4286
|
+
const saves = [];
|
|
4287
|
+
const hashes = [];
|
|
4288
|
+
const unwatch = world.watch({
|
|
4289
|
+
command: (command) => void commands.push([world.tick, structuredClone(command)]),
|
|
4290
|
+
saved: () => void saves.push(world.tick),
|
|
4291
|
+
stepped: () => {
|
|
4292
|
+
if ((world.tick - from) % every === 0) hashes.push([world.tick, worldFingerprint(world)]);
|
|
4293
|
+
if (checkpointEvery > 0 && (world.tick - from) % checkpointEvery === 0) checkpoints.push([world.tick, encodeSave(saveWorld(world))]);
|
|
4294
|
+
}
|
|
4295
|
+
});
|
|
4296
|
+
let replay;
|
|
4297
|
+
return {
|
|
4298
|
+
get ticks() {
|
|
4299
|
+
return world.tick - from;
|
|
4300
|
+
},
|
|
4301
|
+
stop() {
|
|
4302
|
+
if (replay) return replay;
|
|
4303
|
+
unwatch();
|
|
4304
|
+
replay = {
|
|
4305
|
+
format: 2,
|
|
4306
|
+
kind: "commands",
|
|
4307
|
+
game: world.registry.game,
|
|
4308
|
+
rules: world.registry.hash,
|
|
4309
|
+
player: options.player,
|
|
4310
|
+
tickRate: world.tickRate,
|
|
4311
|
+
from,
|
|
4312
|
+
to: world.tick,
|
|
4313
|
+
start,
|
|
4314
|
+
commands,
|
|
4315
|
+
saves,
|
|
4316
|
+
hashes,
|
|
4317
|
+
...checkpoints.length && { checkpoints },
|
|
4318
|
+
...world.debugPlayers.size > 0 && { debug: [...world.debugPlayers] }
|
|
4319
|
+
};
|
|
4320
|
+
return replay;
|
|
4321
|
+
}
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
/**
|
|
4325
|
+
* A replay of commands run again, to its end or a tick, with no screen: the world as it is then, the first tick whose
|
|
4326
|
+
* hash differs from the one kept (none when all were the same), and what differed first at the first checkpoint
|
|
4327
|
+
* (`recordCommands`' `checkpoints`) where the world was not the one recorded: the entity and its field
|
|
4328
|
+
* (`entities[id 11].pos.x`), and what each held. A replay with no checkpoints says the tick alone. What a test of a
|
|
4329
|
+
* replay, and a server checking one, use
|
|
4330
|
+
*
|
|
4331
|
+
* @beta
|
|
4332
|
+
*/
|
|
4333
|
+
async function runReplay(replay, options) {
|
|
4334
|
+
const run = await replayRun(replay, options.game, options.from, options.components);
|
|
4335
|
+
run.stepTo(options.to ?? replay.to);
|
|
4336
|
+
return {
|
|
4337
|
+
world: run.world,
|
|
4338
|
+
apart: run.apart,
|
|
4339
|
+
difference: run.difference
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
/**
|
|
4343
|
+
* `components`: those the app kept in the world recorded beside the game's (`CreateWorldOptions.components`: where the
|
|
4344
|
+
* screen was played from), without which its saves do not load
|
|
4345
|
+
*/
|
|
4346
|
+
async function replayRun(replay, game, at = replay.from, components) {
|
|
4347
|
+
let checkpoint = [replay.from, replay.start];
|
|
4348
|
+
for (const kept of replay.checkpoints ?? []) if (kept[0] <= at && kept[0] > checkpoint[0]) checkpoint = kept;
|
|
4349
|
+
const [startTick, save0] = checkpoint;
|
|
4350
|
+
const world = await loadWorld(decodeSave(save0), {
|
|
4351
|
+
game,
|
|
4352
|
+
anyVersion: true,
|
|
4353
|
+
components
|
|
4354
|
+
});
|
|
4355
|
+
for (const player of replay.debug ?? []) world.debugPlayers.add(player);
|
|
4356
|
+
let command = replay.commands.findIndex(([tick]) => tick >= startTick);
|
|
4357
|
+
if (command < 0) command = replay.commands.length;
|
|
4358
|
+
let save = replay.saves.findIndex((tick) => tick > startTick);
|
|
4359
|
+
if (save < 0) save = replay.saves.length;
|
|
4360
|
+
let hash = replay.hashes.findIndex(([tick]) => tick > startTick);
|
|
4361
|
+
if (hash < 0) hash = replay.hashes.length;
|
|
4362
|
+
const ahead = (replay.checkpoints ?? []).filter(([tick]) => tick > startTick);
|
|
4363
|
+
let next = 0;
|
|
4364
|
+
let apart = null;
|
|
4365
|
+
let difference = null;
|
|
4366
|
+
const cameApart = (tick, what) => {
|
|
4367
|
+
if (apart === null) apart = tick;
|
|
4368
|
+
world.log.warn("replay", `The replay comes apart at tick ${tick}${what ?? ""}: the game is played on as these rules play it`, { tick });
|
|
4369
|
+
};
|
|
4370
|
+
return {
|
|
4371
|
+
world,
|
|
4372
|
+
get apart() {
|
|
4373
|
+
return apart;
|
|
4374
|
+
},
|
|
4375
|
+
get difference() {
|
|
4376
|
+
return difference;
|
|
4377
|
+
},
|
|
4378
|
+
stepTo(target, deadline = Infinity) {
|
|
4379
|
+
let steps = 0;
|
|
4380
|
+
while (world.tick < Math.min(target, replay.to) && !world.failure) {
|
|
4381
|
+
if (++steps % 8 === 0 && performance.now() > deadline) return false;
|
|
4382
|
+
while (command < replay.commands.length && replay.commands[command][0] <= world.tick) world.issue(structuredClone(replay.commands[command++][1]));
|
|
4383
|
+
while (save < replay.saves.length && replay.saves[save] <= world.tick) {
|
|
4384
|
+
saveWorld(world);
|
|
4385
|
+
save++;
|
|
4386
|
+
}
|
|
4387
|
+
world.step();
|
|
4388
|
+
while (hash < replay.hashes.length && replay.hashes[hash][0] < world.tick) hash++;
|
|
4389
|
+
if (hash < replay.hashes.length && replay.hashes[hash][0] === world.tick) {
|
|
4390
|
+
if (apart === null && worldFingerprint(world) !== replay.hashes[hash][1]) cameApart(world.tick);
|
|
4391
|
+
hash++;
|
|
4392
|
+
}
|
|
4393
|
+
while (next < ahead.length && ahead[next][0] < world.tick) next++;
|
|
4394
|
+
if (difference === null && next < ahead.length && ahead[next][0] === world.tick) {
|
|
4395
|
+
const now = decodeSave(encodeSave(saveWorld(world, { reseat: false })));
|
|
4396
|
+
const found = saveDifference(decodeSave(ahead[next][1]), now, world.registry);
|
|
4397
|
+
if (found) {
|
|
4398
|
+
difference = {
|
|
4399
|
+
tick: world.tick,
|
|
4400
|
+
...found
|
|
4401
|
+
};
|
|
4402
|
+
cameApart(world.tick, `, first at ${found.path} (${JSON.stringify(found.a)} recorded, ${JSON.stringify(found.b)} now)`);
|
|
4403
|
+
}
|
|
4404
|
+
next++;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
return true;
|
|
4408
|
+
}
|
|
4409
|
+
};
|
|
4410
|
+
}
|
|
4411
|
+
/**
|
|
4412
|
+
* Two ends of a connection in the page, each message given to the other end once what runs now is done (a microtask):
|
|
4413
|
+
* in order, with no clock, and never inside the call that sent it
|
|
4414
|
+
*/
|
|
4415
|
+
function pair() {
|
|
4416
|
+
const end = () => {
|
|
4417
|
+
let handler = () => {};
|
|
4418
|
+
let open = true;
|
|
4419
|
+
const transport = {
|
|
4420
|
+
send: (message) => {
|
|
4421
|
+
if (open) queueMicrotask(() => other(transport).deliver(message));
|
|
4422
|
+
},
|
|
4423
|
+
onMessage: (on) => void (handler = on),
|
|
4424
|
+
onClose: () => {},
|
|
4425
|
+
close: () => void (open = false),
|
|
4426
|
+
deliver: (message) => {
|
|
4427
|
+
if (open) handler(message);
|
|
4428
|
+
}
|
|
4429
|
+
};
|
|
4430
|
+
return transport;
|
|
4431
|
+
};
|
|
4432
|
+
const a = end();
|
|
4433
|
+
const b = end();
|
|
4434
|
+
const other = (one) => one === a ? b : a;
|
|
4435
|
+
return [a, b];
|
|
4436
|
+
}
|
|
4437
|
+
/** How often a replay being played looks whether its game is due a tick, a second */
|
|
4438
|
+
var LOOKS$1 = 30;
|
|
4439
|
+
/**
|
|
4440
|
+
* Milliseconds of a look a replay being played spends running its game on, at most: what a seek has to catch up on
|
|
4441
|
+
* (minute 15 is 27,000 ticks from the start) is run a slice a look, the page drawn between them
|
|
4442
|
+
*
|
|
4443
|
+
* @beta
|
|
4444
|
+
*/
|
|
4445
|
+
var REPLAY_SLICE = 12;
|
|
4446
|
+
/**
|
|
4447
|
+
* Plays a replay of commands as a watcher's session: its world run again, `speed` times as fast, and served to the
|
|
4448
|
+
* session as a host serves one who watches (`SessionHost`), from `player`'s side, or `options.player`'s. A moment
|
|
4449
|
+
* asked for (`at`, `skipTo`) is run to a slice a look (`REPLAY_SLICE`), from the last checkpoint before it
|
|
4450
|
+
* (`recordCommands`' `checkpoints`) where it is behind: going back starts the world again from there, and the session
|
|
4451
|
+
* takes it up where it is (`RemoteSession.resume`). `behind` is how many ticks are still to run to it. `apart` is the
|
|
4452
|
+
* tick it came apart from the game recorded at, if it did, and `difference` what differed first at the first
|
|
4453
|
+
* checkpoint the world was not the one recorded (`runReplay`)
|
|
4454
|
+
*
|
|
4455
|
+
* @internal
|
|
4456
|
+
*/
|
|
4457
|
+
function playCommands(replay, options) {
|
|
4458
|
+
let run;
|
|
4459
|
+
let host;
|
|
4460
|
+
let timer;
|
|
4461
|
+
let stopped = false;
|
|
4462
|
+
const within = (tick) => Math.min(Math.max(tick, replay.from), replay.to);
|
|
4463
|
+
let due = within(options.at ?? replay.from);
|
|
4464
|
+
/** The world made again for a moment behind it, while it is made */
|
|
4465
|
+
let going;
|
|
4466
|
+
const watcher = options.player ?? replay.player;
|
|
4467
|
+
/** A host for a world, with the watcher's end of a connection to it: the welcome goes out at once */
|
|
4468
|
+
const serve = (world) => {
|
|
4469
|
+
const [served, watching] = pair();
|
|
4470
|
+
const serving = new SessionHost(world, { sendRate: 10 });
|
|
4471
|
+
serving.connect(watcher, served, {
|
|
4472
|
+
readOnly: true,
|
|
4473
|
+
observer: true
|
|
4474
|
+
});
|
|
4475
|
+
return {
|
|
4476
|
+
serving,
|
|
4477
|
+
watching
|
|
4478
|
+
};
|
|
4479
|
+
};
|
|
4480
|
+
const made = replayRun(replay, options.game, due, options.components).then((first) => {
|
|
4481
|
+
run = first;
|
|
4482
|
+
const { serving, watching } = serve(first.world);
|
|
4483
|
+
host = serving;
|
|
4484
|
+
return connect(watching, { game: options.game });
|
|
4485
|
+
});
|
|
4486
|
+
/** Back to a moment before the world: made again from the last checkpoint before it, and the session taken there */
|
|
4487
|
+
const rewind = (session, to) => {
|
|
4488
|
+
going = replayRun(replay, options.game, to, options.components).then((again) => {
|
|
4489
|
+
if (stopped) return again.world.dispose();
|
|
4490
|
+
const { serving, watching } = serve(again.world);
|
|
4491
|
+
const resumed = session.resume(watching);
|
|
4492
|
+
host?.dispose();
|
|
4493
|
+
run?.world.dispose();
|
|
4494
|
+
run = again;
|
|
4495
|
+
host = serving;
|
|
4496
|
+
return resumed;
|
|
4497
|
+
}).catch((error) => run?.world.log.warn("replay", "The replay could not go back", { error: String(error) })).finally(() => going = void 0);
|
|
4498
|
+
};
|
|
4499
|
+
const player = {
|
|
4500
|
+
session: made.then((session) => {
|
|
4501
|
+
if (stopped) return session;
|
|
4502
|
+
let last = performance.now();
|
|
4503
|
+
timer = setInterval(() => {
|
|
4504
|
+
const now = performance.now();
|
|
4505
|
+
const seconds = (now - last) / 1e3;
|
|
4506
|
+
last = now;
|
|
4507
|
+
if (going || !run) return;
|
|
4508
|
+
if (Math.floor(due) < run.world.tick) return rewind(session, Math.floor(due));
|
|
4509
|
+
if (run.world.tick >= Math.floor(due)) due = Math.min(due + seconds * replay.tickRate * player.speed, replay.to);
|
|
4510
|
+
run.stepTo(Math.floor(due), now + 12);
|
|
4511
|
+
host.publish(seconds);
|
|
4512
|
+
}, 1e3 / LOOKS$1);
|
|
4513
|
+
return session;
|
|
4514
|
+
}),
|
|
4515
|
+
get tick() {
|
|
4516
|
+
return run?.world.tick ?? replay.from;
|
|
4517
|
+
},
|
|
4518
|
+
speed: 1,
|
|
4519
|
+
skipTo(tick) {
|
|
4520
|
+
due = within(tick);
|
|
4521
|
+
},
|
|
4522
|
+
get behind() {
|
|
4523
|
+
if (going) return Math.max(Math.floor(due) - replay.from, 1);
|
|
4524
|
+
return Math.max(Math.floor(due) - (run?.world.tick ?? replay.from), 0);
|
|
4525
|
+
},
|
|
4526
|
+
get ended() {
|
|
4527
|
+
return (run?.world.tick ?? replay.from) >= replay.to;
|
|
4528
|
+
},
|
|
4529
|
+
get apart() {
|
|
4530
|
+
return run?.apart ?? null;
|
|
4531
|
+
},
|
|
4532
|
+
get difference() {
|
|
4533
|
+
return run?.difference ?? null;
|
|
4534
|
+
},
|
|
4535
|
+
stop() {
|
|
4536
|
+
stopped = true;
|
|
4537
|
+
clearInterval(timer);
|
|
4538
|
+
host?.dispose();
|
|
4539
|
+
run?.world.dispose();
|
|
4540
|
+
}
|
|
4541
|
+
};
|
|
4542
|
+
return player;
|
|
4543
|
+
}
|
|
4544
|
+
//#endregion
|
|
4545
|
+
//#region src/session/replay.ts
|
|
4546
|
+
/** How often the recorder looks whether the world went on, a second: it keeps what changed at its own rate */
|
|
4547
|
+
var LOOKS = 30;
|
|
4548
|
+
/**
|
|
4549
|
+
* The tick a message says it brings, to answer that it got there: of a delta, from the head of its text where it comes
|
|
4550
|
+
* first; of the welcome, of the first frame it carries
|
|
4551
|
+
*/
|
|
4552
|
+
var tickOf = (message) => {
|
|
4553
|
+
const found = /^\{"type":"delta","tick":(\d+)/.exec(message) ?? (message.startsWith("{\"type\":\"welcome\"") ? /"first":\{"tick":(\d+)/.exec(message) : null);
|
|
4554
|
+
return found ? Number(found[1]) : void 0;
|
|
4555
|
+
};
|
|
4556
|
+
/**
|
|
4557
|
+
* Records a game played in the page (`RecordOptions`): as its start and its players' commands (`kind: 'commands'`,
|
|
4558
|
+
* the default: `recordCommands`), or as an observer from `player`'s side sees it, what changed `sendRate` times a
|
|
4559
|
+
* second (`kind: 'frames'`). Frames are answered at once, as by a watcher who missed nothing, so what is kept is what
|
|
4560
|
+
* changed and not the whole world again
|
|
4561
|
+
*
|
|
4562
|
+
* @beta
|
|
4563
|
+
*/
|
|
4564
|
+
function recordGame(world, options) {
|
|
4565
|
+
if ((options.kind ?? "commands") === "commands") return recordCommands(world, options);
|
|
4566
|
+
const host = new SessionHost(world, { sendRate: options.sendRate ?? 10 });
|
|
4567
|
+
const frames = [];
|
|
4568
|
+
const from = world.tick;
|
|
4569
|
+
let answer = () => {};
|
|
4570
|
+
host.connect(options.player, {
|
|
4571
|
+
send(message) {
|
|
4572
|
+
frames.push([world.tick, message]);
|
|
4573
|
+
const tick = tickOf(message);
|
|
4574
|
+
if (tick !== void 0) answer(JSON.stringify({
|
|
4575
|
+
type: "ack",
|
|
4576
|
+
tick
|
|
4577
|
+
}));
|
|
4578
|
+
},
|
|
4579
|
+
onMessage: (handler) => answer = handler,
|
|
4580
|
+
onClose: () => {},
|
|
4581
|
+
close: () => {}
|
|
4582
|
+
}, {
|
|
4583
|
+
readOnly: true,
|
|
4584
|
+
observer: true
|
|
4585
|
+
});
|
|
4586
|
+
let last = performance.now();
|
|
4587
|
+
const timer = setInterval(() => {
|
|
4588
|
+
const now = performance.now();
|
|
4589
|
+
host.publish((now - last) / 1e3);
|
|
4590
|
+
last = now;
|
|
4591
|
+
}, 1e3 / LOOKS);
|
|
4592
|
+
let replay;
|
|
4593
|
+
return {
|
|
4594
|
+
get ticks() {
|
|
4595
|
+
return world.tick - from;
|
|
4596
|
+
},
|
|
4597
|
+
stop() {
|
|
4598
|
+
if (replay) return replay;
|
|
4599
|
+
clearInterval(timer);
|
|
4600
|
+
host.dispose();
|
|
4601
|
+
replay = {
|
|
4602
|
+
format: 1,
|
|
4603
|
+
game: world.registry.game,
|
|
4604
|
+
player: options.player,
|
|
4605
|
+
tickRate: world.tickRate,
|
|
4606
|
+
from,
|
|
4607
|
+
to: world.tick,
|
|
4608
|
+
frames
|
|
4609
|
+
};
|
|
4610
|
+
return replay;
|
|
4611
|
+
}
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
/**
|
|
4615
|
+
* Plays a replay back, as a watcher's session (`connect`): a replay of commands run again (`playCommands`), or what the
|
|
4616
|
+
* host sent coming in as it came, by the replay's ticks; `speed` times as fast. `game` is the game module it is of, or
|
|
4617
|
+
* the modules the page has, by id
|
|
4618
|
+
*
|
|
4619
|
+
* @beta
|
|
4620
|
+
*/
|
|
4621
|
+
function playReplay(replay, options) {
|
|
4622
|
+
if (replay.format === 2) {
|
|
4623
|
+
const games = options.game;
|
|
4624
|
+
const game = "units" in games && typeof games.id === "string" ? games : games[replay.game];
|
|
4625
|
+
if (!game) throw new Error(`This page does not have the game the replay is of (${replay.game})`);
|
|
4626
|
+
return playCommands(replay, {
|
|
4627
|
+
game,
|
|
4628
|
+
at: options.at,
|
|
4629
|
+
components: options.components
|
|
4630
|
+
});
|
|
4631
|
+
}
|
|
4632
|
+
let deliver = () => {};
|
|
4633
|
+
const watching = () => ({
|
|
4634
|
+
send: () => {},
|
|
4635
|
+
onMessage: (handler) => void (deliver = handler),
|
|
4636
|
+
onClose: () => {},
|
|
4637
|
+
close: () => {}
|
|
4638
|
+
});
|
|
4639
|
+
const within = (tick) => Math.min(Math.max(tick, replay.from), replay.to);
|
|
4640
|
+
let next = 0;
|
|
4641
|
+
/** The moment it is asked to be at: the clock, or where a seek goes */
|
|
4642
|
+
let due = within(options.at ?? replay.from);
|
|
4643
|
+
let last = performance.now();
|
|
4644
|
+
/** The tick of the last frame handed to the session */
|
|
4645
|
+
const shown = () => next > 0 ? replay.frames[next - 1][0] : replay.from;
|
|
4646
|
+
/** Whether frames are still to be handed over to come to the moment asked */
|
|
4647
|
+
const pending = () => next < replay.frames.length && replay.frames[next][0] <= due;
|
|
4648
|
+
let session;
|
|
4649
|
+
const player = {
|
|
4650
|
+
session: connect(watching(), { game: options.game }),
|
|
4651
|
+
get tick() {
|
|
4652
|
+
return pending() ? shown() : due;
|
|
4653
|
+
},
|
|
4654
|
+
speed: 1,
|
|
4655
|
+
skipTo(target) {
|
|
4656
|
+
due = within(target);
|
|
4657
|
+
if (session && due < shown()) {
|
|
4658
|
+
next = 0;
|
|
4659
|
+
session.resume(watching()).catch(() => void 0);
|
|
4660
|
+
deliver(replay.frames[next++]?.[1] ?? "");
|
|
4661
|
+
}
|
|
4662
|
+
},
|
|
4663
|
+
get behind() {
|
|
4664
|
+
return pending() ? Math.max(Math.floor(due) - shown(), 1) : 0;
|
|
4665
|
+
},
|
|
4666
|
+
get ended() {
|
|
4667
|
+
return next >= replay.frames.length;
|
|
4668
|
+
},
|
|
4669
|
+
stop() {
|
|
4670
|
+
stopped = true;
|
|
4671
|
+
clearInterval(timer);
|
|
4672
|
+
}
|
|
4673
|
+
};
|
|
4674
|
+
/** Hands the session the frames up to the moment asked, until `deadline` is past */
|
|
4675
|
+
const flush = (deadline) => {
|
|
4676
|
+
let handed = 0;
|
|
4677
|
+
while (pending()) {
|
|
4678
|
+
deliver(replay.frames[next++][1]);
|
|
4679
|
+
if (++handed % 16 === 0 && performance.now() > deadline) return;
|
|
4680
|
+
}
|
|
4681
|
+
};
|
|
4682
|
+
let timer;
|
|
4683
|
+
let stopped = false;
|
|
4684
|
+
deliver(replay.frames[next++]?.[1] ?? "");
|
|
4685
|
+
player.session.then((made) => {
|
|
4686
|
+
if (stopped) return;
|
|
4687
|
+
session = made;
|
|
4688
|
+
last = performance.now();
|
|
4689
|
+
timer = setInterval(() => {
|
|
4690
|
+
const now = performance.now();
|
|
4691
|
+
if (!pending()) due = Math.min(due + (now - last) / 1e3 * replay.tickRate * player.speed, replay.to);
|
|
4692
|
+
last = now;
|
|
4693
|
+
flush(now + 12);
|
|
4694
|
+
}, 1e3 / LOOKS);
|
|
4695
|
+
}, () => void 0);
|
|
4696
|
+
return player;
|
|
4697
|
+
}
|
|
4698
|
+
//#endregion
|
|
4699
|
+
export { ARRIVE_RANGE, ATTACK_STATS, ATTRIBUTES, ATTRIBUTE_KEYS, AURA_LINGER, AVOIDANCE_LEVELS, Areas, BLOCKS_BUILD, BLOCKS_WALK, BOARD_REACH, BRIDGE, BUILD_REACH, BuildGrid, CAMPAIGN_FORMAT, CAPTURE_FIELDS, CAPTURE_KIND, CHAT_LENGTH, CLASSIC_ATTRIBUTES, COPY_OF, CORE_STAT_KEYS, DATA_KINDS, DEFAULT_AVOIDANCE, DEFAULT_HEIGHTS, DEFAULT_MAX_PLAYERS, DEFAULT_STANCE, DESTRUCTIBLES, DIRECTOR, DIRECTOR_ACTIONS, DIRECTOR_ORDERS, Dialog, ENTITY_DERIVED, ENTITY_SAVE, EXPOSED_FOR, Entity, FAIR_MARGIN, FOG_EXPLORED, FOG_UNEXPLORED, FOG_VISIBLE, FORMATION_GAP, FRIENDLY_FIRE, FogOfWar, HEROES, Invalid, LANE_END_REACH, LANE_SPAWNERS, LANE_SPAWNER_FIELDS, LANE_SPAWNER_KIND, LEGACY_TILESET, LINE_SECONDS, LOG_LEVELS, LocalSession, Logger, MANA_BURN, MAP_CREEPS, MAP_FORMAT, MAP_NEUTRAL, MAP_OBJECTIVES, MAP_OBJECTIVES_KEY, MAP_RULES, MAP_RULES_KEY, MAX_FOG_TEAMS, MAX_LEVEL, MAX_MAP_PLAYERS, MAX_MAP_SIZE, MAX_TILES, MIN_MAP_SIZE, MISSION_RULES, MOBA_STATS, MissingGround, ModError, NEW_DEFINITION, Objectives, PHASES, PING_KINDS, PLAYER_COLORS, PORTALS, PROFILE_SMOOTH, PROTOCOL_VERSION, PackError, Path, Player, RAMP_REACH, RANDOM_RACE, REACH, REPATH_SPREAD, REPLAY_SLICE, REQUIRED_FIELDS, RUNES, Registry, RemoteSession, Rng, SAVE_FORMAT, SCENARIO_FORMAT, SHOP_RANGE, SHUN_TIME, STANCES, STAT_WORDS, SaveError, Scheduler, Score, Sequences, SessionHost, SessionRefused, SessionView, SidePaths, SizedNavigation, TAU, TOMES, Terrain, Timers, UNLOAD_REACH, UPHILL_MISS, UnlistedError, VETERANCY_RANKS, WATER_DEEP, WATER_NONE, WATER_SHALLOW, WorkerSession, World, WorldMap, abilityCast, abilityImpact, abilityReach, abilityRefusal, abilityText, abilityValues, adoptTileset, aimOf, allowed, angleDiff, angleTo, anyEffect, applyBuff, apply as applyDelta, applyHeroRecord, applyTerrainPatch, areaShapeOf, askPause, assembleGame, at, atShop, attackCommand, attackPathOrder, attackReaches, attackStarted, attackStat, attackTiming, attackersOf, attributesOf, autocastCommand, availableTo, availableToPlayer, avoids, bandOf, baseStats, basinShape, blankDefinition, blockByDoodads, board, boardOrder, boarded, bool, bounty, buffApplied, buffEnded, buffRemoved, buildLineCommand, buildNavigationData, builtSoFar, burnMana, buy, buyCommand, campCleared, campFields, campHooks, campLayout, campMember, campNamed, campRespawnSystem, campReturned, camps, campsOf, canAttack, canBoard, canHarvest, canLearn, canPlaceStructure, canRepair, cancelConstructionCommand, cancelTrainCommand, captureAreaOf, captureOf, capturePoint, captured, captures, capturesOf, capturing, cargo, carryIn, carryOut, carryResearch, carryResources, carryUnits, castCommand, castRange, castStarted, castTimeOf, casterFacts, cellLayerValues, cellsChanged, cellsIn, cellsSeen, changedStats, channelEnded, channelStarted, chargesLeft, chat, chatted, checkDamageTable, checkLanes, checkMap, checkMapObjectives, checkMissionRules, checkPack, checkPackSchema, checkRaces, checkRules, checkpoint, chopped, cinematic, cinematicChoice, cinematicChoiceCommand, cinematicCommand, cinematicCue, cinematicCued, cinematicEnded, cinematicPlayingCommand, circleFormation, clamp, clearGround, cloneMap, cloneTerrain, closestPointOnRect, commandHandled, commandRejected, composeGame, compositionOf, connect, consoleSink, constructionCancelled, constructionFinished, constructionOf, constructionSite, constructionStarted, cooldownOf, copyDefinition, coreCommands, coreFormations, coreHooks, coreSystems, coreVictory, corpseSystem, corpses, costRefusal, createItem, createLogRing, createMap, createRegistry, createWorld, createWorldFromMap, cueCinematic, currencyOf, damageMultiplier, damaged, dashing, dataRulesProblem, declaredRefusal, decodeSave, defaultEngageLayer, defaultEngageRoom, defaultHeroRules, defaultPickAttack, defaultRules, defaultWave, defineArea, defineCellLayer, defineCommand, defineComponent, defineEffect, defineEvent, defineGameFile, defineHook, defineHookType, defineIds, defineListener, defineMapField, defineMod, defineObjective, definePrefab, defineRace, defineSequence, defineStat, defineTile, defineTimer, defineTunable, definitionChanged, definitionIn, definitionsIn, delayed, denying, deposit, describeAbility, detection, diff as diffFrames, directing, directorCommand, directorInvulnerable, dispelBuffs, dispelEffects, dist, distSq, distToRect, distanceToEdge, drawFromGroup, drawFromTable, dropOnGround, earn, edgeDistance, editDistance, editableFields, editableValues, effectFields, effectStarted, effectStopped, effectTargets, effectTypesOf, encodeSave, engageLayer, engageWith, engagedLike, engagedWith, engineMessage, engineStats, enteredArea, entityDied, entityMorphed, entityRemoved, entitySpawned, environmentChanged, errorFields, eventById, exactMath, exchange, exchanged, expose, exposure, faceTowards, farthestAcquisition, farthestReach, fieldOptions, fighting, findGround, findItem, fireOnTheMove, fitToPlaces, fitValue, followPathOrder, footprintCell, footprintOf, formatNumber, formationContext, formationPoints, freeSlot, fromRuns, gainStats, gainedStats, gameHash, gameOver, gameReloaded, garrisonHooks, gateChanged, gateStates, gathering, gathers, generateMap, generateTerrain, give, giveBack, giveItem, giveMapObjective, giveResearch, giveResources, grantVeterancy, grantXp, groundAttack, groundBase, groundDifference, groundHash, groundOf, groundReshaped, groundSpeedAt, groundSpeedOfCell, groupChoices, halt, harvesterOf, hasBuff, hasPack, hashInts, healed, heightsFromPixels, heightsToPixels, heroCommands, heroFromRecord, heroLevelUp, heroOf, heroOfState, heroRecord, heroRevived, heroState, heroStrike, heroesOf, hire, hireCommand, hireRefusal, hirerAt, homeOf, hotMod, hotRules, hourOf, id, ids, illusion, impact, inShape, index, innerEffects, invalidateStats, inventoryChanged, inventorySlots, isAvailable, isBlocked, isCommand, isCrowded, isFiniteNumber, isGateOpen, isGroundChanges, isMadeOf, isMovingTo, isNight, isObject, isRooted, isScenery, isServerMessage, isShunned, isSilenced, isSpell, isSpellImmune, isStunned, itemDropped, itemPicked, laneMinion, lanesOf, lastHitsOf, lastTeamStanding, layerOf, layeredNoise, learn, learnCommand, leftArea, lerp, lerpAngle, levelNeeded, lineEnded, lineSaid, list, loadOrder, loadTerrain, loadWorld, logLine, lookAhead, loopback, makeHero, makeWay, manaChanged, mapCampData, mapCell, mapCells, mapDrops, mapFields, mapFileStem, mapGeneratorWorker, mapLanes, mapNamesSource, mapObjectData, mapObjectivePlayers, mapObjectives, mapObjectivesOf, mapPlayerData, mapRegionData, mapTerrain, mapText, mapThings, mapTileIds, mapsFrom, maxLevelOf, maxPlayersOf, mayDeny, measureMap, meets, memoryNavigationCache, mergeMaps, mergeValues, messageSent, migrateMap, missed, missileHit, missileRemoved, missileSpawned, missing, missingTech, missionRuleState, missionRulesOf, missionWon, mitigation, morph, morphCancelled, morphStarted, moveTo, moveToEntity, moving, navigationKey, navigationWorker, nearestDryGround, nested, nestedPartId, newCampaignProgress, noStats, noneFormation, notReady, number, object, objectFields, objectiveChanged, objectiveVictory, onNewGameVersion, onNewModVersion, oneOf, openSocket, optional, orderCommand, orderSays, packFromJson, packsOf, parseMap, passiveEffects, patchAt, patrolPathOrder, pauseAsked, pauseCommand, pausesLeft, pay, penetratingMitigation, pickAttack, pickUp, pileOf, pileTaken, pileType, ping, pingKinds, pinged, placeUnits, placementCells, placementProblem, plain as plainData, playCinematic, playCommands, playEffect, playReplay, playTileset, playerDefeated, playerFacts, playerFields, playerSlot, playerSurrendered, point, portal, portalUsed, prefabsFrom, presetBlight, presetFountain, presetGenerator, presetMarket, presetScore, presetTiles, priceOf, primaryAttribute, productionOf, productionQueue, profileReach, projectileRemoved, projectileSpawned, protocolMismatch, quantize, queueOf, raceOf, rallyCommand, randomSeed, rangeAgainst, rankOf, rankedUp, reachedByDefault, reaches, readCampaignProgress, readHeroRecord, readHostMessage, readMessage, readPlayerMessage, readScenario, recallAbility, recompose, recordCommands, recordGame, redefineIn, redefinitions, refreshVitals, refund, regionCinematic, regionFields, release, reloadGame, removeBuff, removeDefinition, replaceIn, replicateEvent, replicatedEvents, requirementRefusal, researchCommand, researchFinished, resisted, resizeMap, resolveRules, resourceChanged, resourcesDeposited, resourcesPass, resourcesSpent, respawnCamp, respawnIn, respawning, restoreCharges, reviveCommand, reviveCost, reviveHero, rgb, roadsPass, roomFor, roomIn, rootConsole, rootLogger, rotateTowards, rowsFormation, rubbleType, ruleFired, runReplay, rustled, satisfies, saveDifference, saveHeader, saveProblem, saveTerrain, saveWorld, savedGround, scaled, scaledParts, scatterPass, scenarioOfMap, scenarioOfWorld, scenarioWorld, scoreData, scoreOn, seconds, sell, sellCommand, sellValue, sendWave, serializeMap, serveMapGenerator, serveNavigationBuilds, serveWorld, setAnchored, setGate, setGhost, setHeroLevel, setRace, setWallGate, setWaygate, shapeArea, shapeAsBox, shapeAt, shapeBounds, shapeReach, shareGame, shareWith, shieldOf, shootsUphill, shopNear, shopStock, shoreNear, shortOf, shun, sideKnowledge, silences, siteKey, siteOf, siteWords, slotsOf, smartCommand, smoothstep, snapshotOf, snapshotPart, spawnCamp, spawnPrefab, spawnStart, spend, spendCharge, spotFor, squareFormation, stance, stanceCommand, stanceOf, standsInWay, startDash, startedOn, startingBase, startingTerrain, startsPass, statsOf, stepAside, stopCommand, stopEffect, string, summonEffects, summonHooks, summoned, surrender, surrenderCommand, swingTiming, symmetricPlaces, tableItems, takeCorpses, takeItem, talk, targetFacts, targetRefusal, teamChanged, teamMemory, techOf, techTree, tellRules, terrainPass, terrainPatch, terrainPatchProblem, text, tileRemap, tilesFor, tilesPass, tilingNoise, toMessage, toRuns, tooNear, townPortal, traded, trainCommand, trainRefusal, tunableChanged, tuneCommand, tunings, underConstruction, unitHired, unitTrained, unload, unloadOrder, unloaded, unlockRefusal, unlocked, updateMapTerrain, updateSave, upgradeBuildingCommand, upgradeLevel, upgradeOf, upgradesOf, upkeepOf, usePowerUp, validTarget, valueAt, valueText, vec2, veterancy, viewLevel, wait, waitFor, waitUntil, walkBetween, walkingTime, wallGate, wallGateChanged, wallGateCommand, wallJoined, wallLine, wallShapeOf, wavesSent, waygate, waygateTarget, withAttributes, withCapture, withChanges, withConcealment, withData, withDestructibles, withDirector, withFriendlyFire, withHeroes, withLanes, withLastHits, withManaBurn, withMobaStats, withPortals, withRemains, withRespawn, withRunes, withStances, withTomes, withTuning, withValues, withVeterancy, withWalls, withoutEditor, worked, workerSession, worldFailed, worthless, xpGained, xpWorth };
|
|
4700
|
+
|
|
4701
|
+
//# sourceMappingURL=index.js.map
|